feat: initial release of Pwnapple iOS 6 skeuomorphic UI library for Android

This commit is contained in:
John Smith
2026-09-06 20:28:06 -04:00
commit 54361ee6a3
54 changed files with 4475 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Run Tests & Build Release AAR
run: ./gradlew test :pwnapple:assembleRelease :sample:assembleDebug --stacktrace
- name: Upload Library AAR
uses: actions/upload-artifact@v4
with:
name: pwnapple-release-aar
path: pwnapple/build/outputs/aar/pwnapple-release.aar
+35
View File
@@ -0,0 +1,35 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build Release Bundle & AAR
run: ./gradlew :pwnapple:assembleRelease :pwnapple:publishToMavenLocal
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
pwnapple/build/outputs/aar/pwnapple-release.aar
generate_release_notes: true
+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
*/build
.cxx
captures
.externalNativeBuild
.system_generated
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Heretek AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+238
View File
@@ -0,0 +1,238 @@
# Pwnapple 🍎📱
> **Authentic iOS 6 Skeuomorphic Design System & UI Library for Android built with Jetpack Compose.**
[![CI](https://github.com/Heretek-AI/pwnapple/actions/workflows/ci.yml/badge.svg)](https://github.com/Heretek-AI/pwnapple/actions/workflows/ci.yml)
[![JitPack](https://jitpack.io/v/Heretek-AI/pwnapple.svg)](https://jitpack.io/#Heretek-AI/pwnapple)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-Android_API_24+-brightgreen.svg)](https://developer.android.com)
[![Kotlin](https://img.shields.io/badge/Kotlin-2.0+-purple.svg)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack_Compose-2025.02-green.svg)](https://developer.android.com/jetpack/compose)
---
## 🌟 Overview
**Pwnapple** is a pixel-accurate recreation of the iconic, beloved **iOS 6 skeuomorphic design language** for modern Android apps using **Jetpack Compose**.
Remember the days of glossy resin buttons, brushed aluminum, stitched leather, woven linen, crisp drop shadows, and satisfying tactile clicks? Pwnapple brings that warmth, depth, and tangible texture back to mobile development.
### ✨ Features
- 🎨 **Faithful Skeuomorphic Graphics Engine**:
- `Modifier.glossHighlight()`: 3D resin specular curved sheen.
- `Modifier.innerShadow()`: Inset depth for recessed controls.
- `Modifier.skeuoBevel()`: Multi-layer outer borders with top highlight and bottom shadow.
- `Modifier.pinstripeBackground()`: The classic vertical pinstripe wallpaper (`#C5CCD4`).
- `Modifier.darkLinenBackground()`: Dark woven linen texture.
- `ArrowBackShape`: Iconic chevron back button outline with pointed arrowhead.
- 🎛️ **Comprehensive Component Suite**:
- **Navigation**: `IosNavigationBar` (Gloss Blue, Slate Black) & `IosBackButton`.
- **Buttons**: `IosButton` (Silver, Action Blue, Destructive Red, Black) with depressed states & `IosSegmentedControl`.
- **Inputs**: `IosSwitch` (the legendary sliding ON/OFF toggle), `IosSlider`, and inset `IosTextField`.
- **Lists**: `IosGroupedTable`, `IosTableCell` (disclosure chevrons, icons, subtitles, interactive selection), and `IosTableSection`.
- **Tabs**: `IosTabBar` with cyan active glow & `IosBadge` (glossy red notification bubble).
- **Modals**: `IosAlert` (translucent navy UIAlertView) and `IosActionSheet` (bottom linen sheet).
- **Pickers**: `IosPickerWheel` (3D rotating drum cylinder with cylindrical perspective and glass magnifying lens).
- **Indicators**: `IosProgressBar` and `IosActivityIndicator` (12-blade rotating gear spinner).
- 🔊 **Zero-Asset Tactile Audio & Haptics**:
- Embedded real-time PCM audio synthesizer (`SoundPlayer`) using Android's native `AudioTrack`.
- Generates authentic mechanical clicks, drum ratchet ticks, and switch snap latches on the fly with sub-5ms latency and **0 bytes of external audio files**.
- Toggleable or customizable via `LocalPwnappleSoundFeedback`.
- 📱 **Interactive Showcase App**:
- A pixel-perfect clone of the classic iOS 6 **Settings.app** with interactive sub-screens (Wi-Fi network chooser, Sounds ringer slider, About screen).
- Live interactive Component Catalog and Dialog Playground.
---
## 📦 Installation
Add the JitPack repository to your root `settings.gradle.kts`:
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
```
Add the dependency to your module's `build.gradle.kts`:
```kotlin
dependencies {
implementation("com.github.Heretek-AI:pwnapple:1.0.0")
}
```
---
## 🚀 Quick Start
### 1. Wrap Your App in `PwnappleTheme`
```kotlin
import ai.heretek.pwnapple.theme.PwnappleTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PwnappleTheme(soundFeedbackEnabled = true) {
MyAppContent()
}
}
}
}
```
### 2. Grouped Table (Settings Style)
```kotlin
import ai.heretek.pwnapple.components.input.IosSwitch
import ai.heretek.pwnapple.components.table.IosCellAccessory
import ai.heretek.pwnapple.components.table.IosTableCell
import ai.heretek.pwnapple.components.table.IosTableSection
import ai.heretek.pwnapple.graphics.pinstripeBackground
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
) {
IosTableSection(
headerText = "CONNECTIVITY",
footerText = "Turn on Airplane Mode to disable all wireless connections."
) {
IosTableCell(
title = "Airplane Mode",
accessoryContent = {
IosSwitch(
checked = isAirplaneMode,
onCheckedChange = { isAirplaneMode = it }
)
}
)
IosTableCell(
title = "Wi-Fi",
detailText = "Heretek_5G",
accessory = IosCellAccessory.DisclosureIndicator,
onClick = { /* Navigate to Wi-Fi settings */ }
)
}
}
```
### 3. Navigation Bar & Chevron Back Button
```kotlin
import ai.heretek.pwnapple.components.navigation.IosBackButton
import ai.heretek.pwnapple.components.navigation.IosNavigationBar
IosNavigationBar(
title = "General",
navigationIcon = {
IosBackButton(text = "Settings", onClick = { navController.popBackStack() })
}
)
```
### 4. Skeuomorphic Buttons
```kotlin
import ai.heretek.pwnapple.components.button.IosButton
import ai.heretek.pwnapple.components.button.IosButtonStyle
// Standard Silver Button
IosButton(
text = "Save to Camera Roll",
onClick = { /* action */ },
style = IosButtonStyle.Silver
)
// Blue Action Button
IosButton(
text = "Confirm Purchase",
onClick = { /* action */ },
style = IosButtonStyle.Blue
)
// Destructive Red Button
IosButton(
text = "Delete Account",
onClick = { /* action */ },
style = IosButtonStyle.Red
)
```
### 5. 3D Drum Wheel Picker (UIPickerView)
```kotlin
import ai.heretek.pwnapple.components.picker.IosPickerWheel
var selectedIndex by remember { mutableIntStateOf(0) }
val items = listOf("iPhone 3GS", "iPhone 4", "iPhone 4S", "iPhone 5")
IosPickerWheel(
items = items,
selectedIndex = selectedIndex,
onSelectedIndexChange = { selectedIndex = it }
)
```
### 6. Modal Dialogs (UIAlertView & UIActionSheet)
```kotlin
import ai.heretek.pwnapple.components.dialog.IosAlert
import ai.heretek.pwnapple.components.dialog.IosActionSheet
if (showAlert) {
IosAlert(
title = "Cannot Connect to iTunes Store",
message = "Please verify your internet connection and try again.",
confirmButtonText = "OK",
onDismissRequest = { showAlert = false }
)
}
```
---
## 🔊 Audio Feedback Customization
Audio cues are generated on-the-fly using native PCM audio:
- To disable audio globally:
```kotlin
PwnappleTheme(soundFeedbackEnabled = false) { ... }
```
- To toggle dynamically at runtime:
```kotlin
val soundFeedback = LocalPwnappleSoundFeedback.current
soundFeedback.enabled = false // silences all clicks
```
---
## 🛠️ Building & Running the Sample App
1. Clone the repository:
```bash
git clone https://github.com/Heretek-AI/pwnapple.git
cd pwnapple
```
2. Build the library and sample application:
```bash
./gradlew :pwnapple:assembleRelease :sample:assembleDebug
```
3. Run tests:
```bash
./gradlew test
```
---
## 📄 License
Pwnapple is open-source software licensed under the [MIT License](LICENSE).
Created and maintained by [Heretek AI](https://github.com/Heretek-AI).
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
alias(libs.plugins.compose.compiler) apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.java.home=/home/linuxbrew/.linuxbrew/opt/openjdk@21
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+36
View File
@@ -0,0 +1,36 @@
[versions]
agp = "8.10.1"
kotlin = "2.0.0"
coreKtx = "1.16.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
navigationCompose = "2.8.9"
composeBom = "2025.02.00"
junit = "4.13.2"
androidxJunit = "1.2.1"
espressoCore = "3.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
maven-publish = { id = "maven-publish" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+253
View File
@@ -0,0 +1,253 @@
#!/bin/sh
if [ ! -d "$JAVA_HOME" ]; then export JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21; fi
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+6
View File
@@ -0,0 +1,6 @@
jdk:
- openjdk21
before_install:
- ./gradlew -v
install:
- ./gradlew :pwnapple:publishToMavenLocal
+98
View File
@@ -0,0 +1,98 @@
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.maven.publish)
}
android {
namespace = "ai.heretek.pwnapple"
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
publishing {
singleVariant("release") {
withSourcesJar()
withJavadocJar()
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
testImplementation(libs.junit)
}
afterEvaluate {
publishing {
publications {
create<MavenPublication>("release") {
from(components["release"])
groupId = "ai.heretek.pwnapple"
artifactId = "pwnapple"
version = "1.0.0"
pom {
name.set("Pwnapple")
description.set("Authentic iOS 6 skeuomorphic UI library for Android built with Jetpack Compose")
url.set("https://github.com/Heretek-AI/pwnapple")
licenses {
license {
name.set("The MIT License")
url.set("https://opensource.org/licenses/MIT")
}
}
developers {
developer {
id.set("heretek-ai")
name.set("Heretek AI")
email.set("[email protected]")
}
}
scm {
connection.set("scm:git:git://github.com/Heretek-AI/pwnapple.git")
developerConnection.set("scm:git:ssh://github.com/Heretek-AI/pwnapple.git")
url.set("https://github.com/Heretek-AI/pwnapple")
}
}
}
}
}
}
View File
View File
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,129 @@
package ai.heretek.pwnapple.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import java.util.concurrent.Executors
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
/**
* Synthesizes and plays low-latency, authentic skeuomorphic tactile audio cues
* directly via PCM AudioTrack without needing external audio asset files.
*/
object SoundPlayer {
private const val SAMPLE_RATE = 44100
private val executor = Executors.newSingleThreadExecutor()
private val clickBuffer: ShortArray by lazy { generateButtonClick() }
private val tickBuffer: ShortArray by lazy { generateWheelTick() }
private val latchBuffer: ShortArray by lazy { generateSwitchLatch() }
fun playButtonClick() {
playSample(clickBuffer)
}
fun playWheelTick() {
playSample(tickBuffer)
}
fun playSwitchLatch() {
playSample(latchBuffer)
}
private fun playSample(buffer: ShortArray) {
executor.execute {
try {
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(buffer.size * 2)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
track.write(buffer, 0, buffer.size)
track.play()
// Sleep until playback completes then release track
Thread.sleep((buffer.size * 1000L / SAMPLE_RATE) + 10)
track.stop()
track.release()
} catch (_: Throwable) {
// Silently ignore if audio subsystem is unavailable
}
}
}
/**
* Crisp 22ms mechanical tactile click with fast attack and exponential decay.
*/
private fun generateButtonClick(): ShortArray {
val durationMs = 22
val numSamples = (SAMPLE_RATE * durationMs) / 1000
val buffer = ShortArray(numSamples)
val f1 = 1750.0
val f2 = 820.0
for (i in 0 until numSamples) {
val t = i.toDouble() / SAMPLE_RATE
val env = exp(-t * 220.0)
val wave = 0.7 * sin(2.0 * PI * f1 * t) + 0.3 * sin(2.0 * PI * f2 * t)
buffer[i] = (wave * env * 24000.0).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
return buffer
}
/**
* 10ms high-pitch mechanical ratchet tick for picker drum wheels.
*/
private fun generateWheelTick(): ShortArray {
val durationMs = 12
val numSamples = (SAMPLE_RATE * durationMs) / 1000
val buffer = ShortArray(numSamples)
val freq = 3100.0
for (i in 0 until numSamples) {
val t = i.toDouble() / SAMPLE_RATE
val env = exp(-t * 450.0)
val wave = sin(2.0 * PI * freq * t)
buffer[i] = (wave * env * 20000.0).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
return buffer
}
/**
* 32ms dual-impulse snap for the classic spring-loaded sliding switch.
*/
private fun generateSwitchLatch(): ShortArray {
val durationMs = 32
val numSamples = (SAMPLE_RATE * durationMs) / 1000
val buffer = ShortArray(numSamples)
for (i in 0 until numSamples) {
val t = i.toDouble() / SAMPLE_RATE
// Phase 1: onset low thump
val env1 = if (t < 0.012) exp(-t * 300.0) else 0.0
val wave1 = sin(2.0 * PI * 550.0 * t)
// Phase 2: sharp metallic snap 7ms later
val t2 = t - 0.007
val env2 = if (t2 > 0.0) exp(-t2 * 280.0) else 0.0
val wave2 = sin(2.0 * PI * 2200.0 * t2)
val combined = (wave1 * env1 * 0.4) + (wave2 * env2 * 0.7)
buffer[i] = (combined * 25000.0).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
return buffer
}
}
@@ -0,0 +1,119 @@
package ai.heretek.pwnapple.components.button
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.graphics.skeuoBevel
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class IosButtonStyle {
Silver,
Blue,
Red,
Black
}
enum class IosButtonSize {
Normal,
Small
}
/**
* Authentic iOS 6 glossy beveled button with tactile pressed states,
* specular reflection, and click audio.
*/
@Composable
fun IosButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: IosButtonStyle = IosButtonStyle.Silver,
size: IosButtonSize = IosButtonSize.Normal,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val colors = PwnappleTheme.colors
val typography = PwnappleTheme.typography
val sound = PwnappleTheme.sound
val isPressed by interactionSource.collectIsPressedAsState()
val cornerRadius: Dp = if (size == IosButtonSize.Small) 4.5.dp else 6.dp
val shape = remember(cornerRadius) { RoundedCornerShape(cornerRadius) }
val brush = when (style) {
IosButtonStyle.Silver -> colors.buttonSilverBrush(isPressed)
IosButtonStyle.Blue -> colors.buttonBlueBrush(isPressed)
IosButtonStyle.Red -> colors.buttonRedBrush(isPressed)
IosButtonStyle.Black -> if (!isPressed) colors.navBarBlackBrush() else colors.navBarBlackBrush()
}
val borderColor = when (style) {
IosButtonStyle.Silver -> colors.buttonSilverBorder
IosButtonStyle.Blue -> colors.buttonBlueBorder
IosButtonStyle.Red -> colors.buttonRedBorder
IosButtonStyle.Black -> Color.Black
}
val textStyle: TextStyle = when (style) {
IosButtonStyle.Silver -> typography.buttonSilverText
else -> typography.buttonColorText
}
val minHeight: Dp = if (size == IosButtonSize.Small) 30.dp else 42.dp
val horizontalPadding: Dp = if (size == IosButtonSize.Small) 10.dp else 18.dp
Box(
modifier = modifier
.defaultMinSize(minHeight = minHeight)
.clip(shape)
.background(brush)
.skeuoBevel(
cornerRadius = cornerRadius,
borderColor = borderColor,
highlightColor = if (isPressed) Color.Transparent else Color.White.copy(alpha = 0.4f),
shadowColor = if (isPressed) Color.Black.copy(alpha = 0.4f) else Color.Black.copy(alpha = 0.2f)
)
.then(
if (isPressed) {
Modifier.innerShadow(cornerRadius = cornerRadius, shadowColor = Color(0x66000000), shadowHeight = 3.dp)
} else {
Modifier.glossHighlight(cornerRadius = cornerRadius, glossAlpha = 0.42f)
}
)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null
) {
sound.playButtonClick()
onClick()
}
.padding(horizontal = horizontalPadding, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
style = textStyle,
modifier = Modifier.offset(y = if (isPressed) 1.dp else 0.dp)
)
}
}
@@ -0,0 +1,147 @@
package ai.heretek.pwnapple.components.button
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Connected segmented control with active/inactive skeuomorphic gradient styling.
*/
@Composable
fun IosSegmentedControl(
items: List<String>,
selectedIndex: Int,
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
height: androidx.compose.ui.unit.Dp = 30.dp
) {
val sound = PwnappleTheme.sound
val outerShape = remember { RoundedCornerShape(4.dp) }
Row(
modifier = modifier
.height(height)
.clip(outerShape)
.border(1.dp, Color(0xFF6B7E96), outerShape)
) {
items.forEachIndexed { index, title ->
val isSelected = index == selectedIndex
val segmentShape = when {
items.size == 1 -> outerShape
index == 0 -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp)
index == items.lastIndex -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp)
else -> RoundedCornerShape(0.dp)
}
val brush = if (isSelected) {
Brush.verticalGradient(
listOf(
Color(0xFF33537E),
Color(0xFF4C73A6),
Color(0xFF5B84B8)
)
)
} else {
Brush.verticalGradient(
listOf(
Color(0xFFFBFBFB),
Color(0xFFE2E2E2),
Color(0xFFD0D0D0)
)
)
}
val textStyle = if (isSelected) {
TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.6f),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
)
} else {
TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = Color(0xFF333333),
shadow = Shadow(
color = Color.White.copy(alpha = 0.8f),
offset = Offset(0f, 1.5f),
blurRadius = 0f
)
)
}
Box(
modifier = Modifier
.weight(1f)
.clip(segmentShape)
.background(brush)
.then(
if (isSelected) {
Modifier.innerShadow(cornerRadius = 0.dp, shadowColor = Color(0x66000000), shadowHeight = 3.dp)
} else {
Modifier.glossHighlight(cornerRadius = 0.dp, glossAlpha = 0.35f)
}
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (index != selectedIndex) {
sound.playButtonClick()
onItemSelected(index)
}
}
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = title,
style = textStyle
)
}
// Divider line between segments
if (index < items.lastIndex) {
Box(
modifier = Modifier
.height(height)
.background(Color(0xFF6B7E96))
.defaultMinSize(minWidth = 1.dp)
)
}
}
}
}
@@ -0,0 +1,134 @@
package ai.heretek.pwnapple.components.dialog
import ai.heretek.pwnapple.components.button.IosButton
import ai.heretek.pwnapple.components.button.IosButtonStyle
import ai.heretek.pwnapple.graphics.darkLinenBackground
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Skeuomorphic iOS 6 bottom action sheet with dark translucent container,
* optional title, action buttons, destructive button, and cancel button.
*/
@Composable
fun IosActionSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
destructiveText: String? = null,
onDestructiveClick: (() -> Unit)? = null,
actionButtons: List<Pair<String, () -> Unit>> = emptyList(),
cancelText: String = "Cancel",
onCancelClick: () -> Unit = onDismissRequest
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onDismissRequest() },
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {} // consume clicks inside sheet
.background(Color(0xE61E242B))
.darkLinenBackground()
.drawBehind {
// Top 1px highlight line
drawLine(
color = Color.White.copy(alpha = 0.25f),
start = Offset(0f, 0.5f),
end = Offset(size.width, 0.5f),
strokeWidth = 1f
)
}
.padding(16.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
if (!title.isNullOrEmpty()) {
Text(
text = title,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color(0xFFC0CAD5),
shadow = Shadow(
color = Color.Black.copy(alpha = 0.8f),
offset = Offset(0f, -1f),
blurRadius = 0f
)
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
}
// Destructive button (Red)
if (destructiveText != null && onDestructiveClick != null) {
IosButton(
text = destructiveText,
onClick = { onDestructiveClick(); onDismissRequest() },
style = IosButtonStyle.Red,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
// Standard action buttons (Silver)
actionButtons.forEach { (btnText, action) ->
IosButton(
text = btnText,
onClick = { action(); onDismissRequest() },
style = IosButtonStyle.Silver,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
// Cancel button (Black/Dark)
IosButton(
text = cancelText,
onClick = { onCancelClick(); onDismissRequest() },
style = IosButtonStyle.Black,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@@ -0,0 +1,162 @@
package ai.heretek.pwnapple.components.dialog
import ai.heretek.pwnapple.components.button.IosButton
import ai.heretek.pwnapple.components.button.IosButtonStyle
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.skeuoBevel
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
/**
* The iconic iOS 6 UIAlertView with deep navy translucent gradient,
* silver beveled trim, embossed title, and glossy action buttons.
*/
@Composable
fun IosAlert(
title: String,
message: String,
onDismissRequest: () -> Unit,
confirmButtonText: String = "OK",
onConfirm: () -> Unit = onDismissRequest,
dismissButtonText: String? = null,
onDismiss: (() -> Unit)? = null
) {
val colors = PwnappleTheme.colors
val shape = remember { RoundedCornerShape(12.dp) }
Dialog(onDismissRequest = onDismissRequest) {
Box(
modifier = Modifier
.width(280.dp)
.drawBehind {
// Outer dialog drop shadow
drawRoundRect(
color = Color.Black.copy(alpha = 0.6f),
topLeft = Offset(0f, 4f),
size = size,
cornerRadius = CornerRadius(12.dp.toPx(), 12.dp.toPx())
)
}
.clip(shape)
.background(
Brush.verticalGradient(
listOf(
colors.alertBackgroundTop.copy(alpha = 0.95f),
colors.alertBackgroundBottom.copy(alpha = 0.95f)
)
)
)
.skeuoBevel(
cornerRadius = 12.dp,
borderColor = colors.alertBorder,
borderWidth = 1.5.dp,
highlightColor = Color.White.copy(alpha = 0.4f),
shadowColor = Color.Black.copy(alpha = 0.5f)
)
.glossHighlight(cornerRadius = 12.dp, glossAlpha = 0.32f)
.padding(16.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
// Alert Title
Text(
text = title,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.8f),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Alert Message
Text(
text = message,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
color = Color(0xFFD6E2F0),
shadow = Shadow(
color = Color.Black.copy(alpha = 0.6f),
offset = Offset(0f, -1f),
blurRadius = 0f
)
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Buttons
if (dismissButtonText != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IosButton(
text = dismissButtonText,
onClick = { onDismiss?.invoke(); onDismissRequest() },
style = IosButtonStyle.Silver,
modifier = Modifier.weight(1f)
)
IosButton(
text = confirmButtonText,
onClick = { onConfirm(); onDismissRequest() },
style = IosButtonStyle.Blue,
modifier = Modifier.weight(1f)
)
}
} else {
IosButton(
text = confirmButtonText,
onClick = { onConfirm(); onDismissRequest() },
style = IosButtonStyle.Blue,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
@@ -0,0 +1,65 @@
package ai.heretek.pwnapple.components.indicator
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Authentic 12-blade iOS 6 activity indicator (spinner).
*/
@Composable
fun IosActivityIndicator(
modifier: Modifier = Modifier,
size: Dp = 24.dp,
baseColor: Color = Color(0xFF5C5C5C)
) {
val infiniteTransition = rememberInfiniteTransition(label = "spinnerTransition")
val step by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 12f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "spinnerStep"
)
val currentStep = step.toInt() % 12
Canvas(modifier = modifier.size(size)) {
val bladeWidth = this.size.width * 0.09f
val bladeHeight = this.size.height * 0.28f
val radius = this.size.width / 2f
for (i in 0 until 12) {
val alphaIndex = (i - currentStep + 12) % 12
// Blades fade from 1.0 down to 0.15
val alpha = (1f - (alphaIndex / 12f) * 0.85f).coerceIn(0.15f, 1f)
rotate(degrees = i * 30f, pivot = Offset(radius, radius)) {
drawRoundRect(
color = baseColor.copy(alpha = alpha),
topLeft = Offset(radius - bladeWidth / 2f, this.size.height * 0.05f),
size = Size(bladeWidth, bladeHeight),
cornerRadius = CornerRadius(bladeWidth / 2f, bladeWidth / 2f)
)
}
}
}
}
@@ -0,0 +1,61 @@
package ai.heretek.pwnapple.components.indicator
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Skeuomorphic iOS 6 progress bar with glossy cyan-blue progress and recessed track.
*/
@Composable
fun IosProgressBar(
progress: Float, // 0f..1f
modifier: Modifier = Modifier
) {
val colors = PwnappleTheme.colors
val clamped = progress.coerceIn(0f, 1f)
val shape = remember { RoundedCornerShape(5.dp) }
Box(
modifier = modifier
.fillMaxWidth()
.height(10.dp)
.clip(shape)
.background(
Brush.verticalGradient(
listOf(Color(0xFFC0C0C0), Color(0xFFE2E2E2))
)
)
.border(0.75.dp, Color(0xFF9E9E9E), shape)
.innerShadow(cornerRadius = 5.dp, shadowColor = Color(0x66000000), shadowHeight = 2.dp)
) {
if (clamped > 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(clamped)
.clip(shape)
.background(
Brush.verticalGradient(
listOf(colors.switchOnTop, colors.switchOnBottom)
)
)
.glossHighlight(cornerRadius = 5.dp, glossAlpha = 0.35f)
)
}
}
}
@@ -0,0 +1,157 @@
package ai.heretek.pwnapple.components.input
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Skeuomorphic slider with filled cyan glossy progress bar,
* inset unfilled track, and metallic thumb disc with grip grooves.
*/
@Composable
fun IosSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
enabled: Boolean = true
) {
val colors = PwnappleTheme.colors
var trackWidthPx by remember { mutableFloatStateOf(0f) }
val thumbSize = 26.dp
val trackHeight = 8.dp
val trackShape = remember { RoundedCornerShape(4.dp) }
val normalized = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(36.dp)
.onSizeChanged { trackWidthPx = it.width.toFloat() }
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectTapGestures { offset ->
val newNorm = (offset.x / trackWidthPx).coerceIn(0f, 1f)
val newValue = valueRange.start + newNorm * (valueRange.endInclusive - valueRange.start)
onValueChange(newValue)
}
}
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectDragGestures { change, _ ->
val newNorm = (change.position.x / trackWidthPx).coerceIn(0f, 1f)
val newValue = valueRange.start + newNorm * (valueRange.endInclusive - valueRange.start)
onValueChange(newValue)
}
},
contentAlignment = Alignment.CenterStart
) {
// Unfilled background track
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.clip(trackShape)
.background(
Brush.verticalGradient(
listOf(Color(0xFFC0C0C0), Color(0xFFE2E2E2))
)
)
.border(0.75.dp, Color(0xFF9E9E9E), trackShape)
.innerShadow(cornerRadius = 4.dp, shadowColor = Color(0x66000000), shadowHeight = 2.dp)
)
// Filled active progress track
if (trackWidthPx > 0f) {
val fillWidth = (trackWidthPx * normalized)
Box(
modifier = Modifier
.size(
width = with(androidx.compose.ui.platform.LocalDensity.current) { fillWidth.toDp() },
height = trackHeight
)
.clip(trackShape)
.background(
Brush.verticalGradient(
listOf(colors.switchOnTop, colors.switchOnBottom)
)
)
.glossHighlight(cornerRadius = 4.dp, glossAlpha = 0.35f)
)
}
// Metallic circular thumb
val thumbOffsetPx = if (trackWidthPx > 0f) {
val maxTravel = trackWidthPx - with(androidx.compose.ui.platform.LocalDensity.current) { thumbSize.toPx() }
(maxTravel * normalized).coerceAtLeast(0f)
} else 0f
Box(
modifier = Modifier
.offset { IntOffset(thumbOffsetPx.roundToInt(), 0) }
.size(thumbSize)
.drawBehind {
// Outer drop shadow
drawCircle(
color = Color.Black.copy(alpha = 0.35f),
radius = size.minDimension / 2f,
center = center + Offset(0f, 1.5f)
)
}
.clip(CircleShape)
.background(
Brush.verticalGradient(
listOf(
Color(0xFFFFFFFF),
Color(0xFFEDEDED),
Color(0xFFDCDCDC)
)
)
)
.border(1.dp, Color(0xFFA6A6A6), CircleShape)
.glossHighlight(cornerRadius = 13.dp, glossAlpha = 0.45f)
.drawBehind {
// Center metallic grip lines
val cx = size.width / 2f
val cy = size.height / 2f
for (dy in listOf(-3f, 0f, 3f)) {
drawLine(
color = Color(0xFF8B8B8B),
start = Offset(cx - 4f, cy + dy),
end = Offset(cx + 4f, cy + dy),
strokeWidth = 1f
)
}
}
)
}
}
@@ -0,0 +1,169 @@
package ai.heretek.pwnapple.components.input
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
/**
* The iconic iOS 6 sliding switch with glossy cyan "ON" and silver "OFF" tracks,
* beveled circular metal thumb, embossed labels, and tactile latch audio.
*/
@Composable
fun IosSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
val sound = PwnappleTheme.sound
val colors = PwnappleTheme.colors
val trackWidth = 76.dp
val trackHeight = 28.dp
val thumbSize = 28.dp
val cornerRadius = 14.dp
val trackShape = remember { RoundedCornerShape(cornerRadius) }
// Progress: 0.0 (OFF) -> 1.0 (ON)
val progress by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = spring(dampingRatio = 0.85f, stiffness = 600f),
label = "switchThumbPosition"
)
Box(
modifier = modifier
.width(trackWidth)
.height(trackHeight)
.clip(trackShape)
.border(1.dp, Color(0xFF949494), trackShape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
sound.playSwitchLatch()
onCheckedChange(!checked)
}
) {
// Track layer
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (checked) colors.switchOnBrush() else colors.switchOffBrush()
)
.innerShadow(cornerRadius = cornerRadius, shadowColor = Color(0x66000000), shadowHeight = 4.dp)
.glossHighlight(cornerRadius = cornerRadius, glossAlpha = 0.25f)
) {
// "ON" text when checked
if (progress > 0.4f) {
Text(
text = "ON",
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = Color.White.copy(alpha = progress),
shadow = Shadow(
color = Color.Black.copy(alpha = 0.45f * progress),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
),
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 12.dp)
)
}
// "OFF" text when unchecked
if (progress < 0.6f) {
Text(
text = "OFF",
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = Color(0xFF7D7D7D).copy(alpha = 1f - progress),
shadow = Shadow(
color = Color.White.copy(alpha = 0.9f * (1f - progress)),
offset = Offset(0f, 1.5f),
blurRadius = 0f
)
),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
)
}
}
// Circular Thumb Knob
val maxOffsetPx = with(androidx.compose.ui.platform.LocalDensity.current) {
(trackWidth - thumbSize).toPx()
}
Box(
modifier = Modifier
.offset { IntOffset(x = (maxOffsetPx * progress).roundToInt(), y = 0) }
.size(thumbSize)
.drawBehind {
// Drop shadow on knob
drawCircle(
color = Color.Black.copy(alpha = 0.35f),
radius = size.minDimension / 2f,
center = center + Offset(if (checked) -2f else 2f, 1.5f)
)
}
.clip(CircleShape)
.background(
Brush.verticalGradient(
listOf(
colors.switchKnobTop,
Color(0xFFEDEDED),
colors.switchKnobBottom
)
)
)
.border(1.dp, Color(0xFFB0B0B0), CircleShape)
.glossHighlight(cornerRadius = cornerRadius, glossAlpha = 0.45f)
)
}
}
@@ -0,0 +1,122 @@
package ai.heretek.pwnapple.components.input
import ai.heretek.pwnapple.graphics.innerShadow
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Inset skeuomorphic text field with inner shadow, clear button, and placeholder.
*/
@Composable
fun IosTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
leadingIcon: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
val shape = remember { RoundedCornerShape(5.dp) }
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 34.dp)
.clip(shape)
.background(Color.White)
.border(1.dp, Color(0xFFA5A5A5), shape)
.innerShadow(cornerRadius = 5.dp, shadowColor = Color(0x40000000), shadowHeight = 3.dp)
.padding(horizontal = 8.dp, vertical = 6.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (leadingIcon != null) {
Box(modifier = Modifier.padding(end = 6.dp)) {
leadingIcon()
}
}
Box(modifier = Modifier.weight(1f)) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
text = placeholder,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 15.sp,
color = Color(0xFF9E9E9E)
)
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = 15.sp,
color = Color.Black
),
cursorBrush = SolidColor(Color(0xFF0272EC)),
singleLine = singleLine,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth()
)
}
if (value.isNotEmpty()) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(Color(0xFF8E8E93))
.clickable { onValueChange("") },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear text",
tint = Color.White,
modifier = Modifier.size(12.dp)
)
}
}
}
}
}
@@ -0,0 +1,95 @@
package ai.heretek.pwnapple.components.navigation
import ai.heretek.pwnapple.graphics.ArrowBackShape
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The iconic iOS 6 chevron back button with arrow point,
* glossy gradient fill, and embossed title text.
*/
@Composable
fun IosBackButton(
text: String = "Back",
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val isPressed by interactionSource.collectIsPressedAsState()
val sound = PwnappleTheme.sound
val shape = remember { ArrowBackShape(arrowWidth = 13.dp, cornerRadius = 4.5.dp) }
val brush = if (!isPressed) {
Brush.verticalGradient(
listOf(
Color(0xFF7A94B5),
Color(0xFF567298),
Color(0xFF425E85)
)
)
} else {
Brush.verticalGradient(
listOf(
Color(0xFF334A6A),
Color(0xFF486386)
)
)
}
Box(
modifier = modifier
.height(30.dp)
.clip(shape)
.background(brush)
.border(1.dp, Color(0xFF263952), shape)
.glossHighlight(cornerRadius = 4.dp, glossAlpha = 0.35f)
.clickable(
interactionSource = interactionSource,
indication = null
) {
sound.playButtonClick()
onClick()
}
.padding(start = 14.dp, end = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.65f),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
)
)
}
}
@@ -0,0 +1,114 @@
package ai.heretek.pwnapple.components.navigation
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
enum class IosNavBarStyle {
DefaultBlue,
BlackTranslucent,
BlackOpaque
}
/**
* Standard iOS 6 navigation bar with glossy gradient, embossed center title,
* specular highlight line, and bottom hairline border.
*/
@Composable
fun IosNavigationBar(
title: String,
modifier: Modifier = Modifier,
style: IosNavBarStyle = IosNavBarStyle.DefaultBlue,
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
val colors = PwnappleTheme.colors
val typography = PwnappleTheme.typography
val brush = when (style) {
IosNavBarStyle.DefaultBlue -> colors.navBarBlueBrush()
IosNavBarStyle.BlackTranslucent -> Brush.verticalGradient(
listOf(colors.navBarBlackTop.copy(alpha = 0.88f), colors.navBarBlackBottom.copy(alpha = 0.88f))
)
IosNavBarStyle.BlackOpaque -> colors.navBarBlackBrush()
}
val bottomBorderColor = when (style) {
IosNavBarStyle.DefaultBlue -> Color(0xFF2A374A)
else -> Color(0xFF000000)
}
Box(
modifier = modifier
.fillMaxWidth()
.height(44.dp)
.background(brush)
.glossHighlight(cornerRadius = 0.dp, glossAlpha = 0.28f)
.drawBehind {
// Top 1px specular line
drawLine(
color = Color.White.copy(alpha = 0.35f),
start = Offset(0f, 0.5f),
end = Offset(size.width, 0.5f),
strokeWidth = 1f
)
// Bottom 1px hairline border
drawLine(
color = bottomBorderColor,
start = Offset(0f, size.height - 0.5f),
end = Offset(size.width, size.height - 0.5f),
strokeWidth = 1f
)
// Bottom drop shadow
drawLine(
color = Color.Black.copy(alpha = 0.3f),
start = Offset(0f, size.height + 0.5f),
end = Offset(size.width, size.height + 0.5f),
strokeWidth = 1.5f
)
}
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center
) {
// Left navigation icon
if (navigationIcon != null) {
Box(
modifier = Modifier.align(Alignment.CenterStart)
) {
navigationIcon()
}
}
// Embossed center title
Text(
text = title,
style = typography.navTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 48.dp)
)
// Right actions
Row(
modifier = Modifier.align(Alignment.CenterEnd),
verticalAlignment = Alignment.CenterVertically,
content = actions
)
}
}
@@ -0,0 +1,221 @@
package ai.heretek.pwnapple.components.picker
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.roundToInt
/**
* The iconic 3D drum cylinder wheel picker (UIPickerView) with cylindrical
* perspective curvature, glass lens overlay, and ratchet audio ticks.
*/
@Composable
fun IosPickerWheel(
items: List<String>,
selectedIndex: Int,
onSelectedIndexChange: (Int) -> Unit,
modifier: Modifier = Modifier,
visibleItemsCount: Int = 5,
itemHeight: androidx.compose.ui.unit.Dp = 38.dp
) {
if (items.isEmpty()) return
val sound = PwnappleTheme.sound
val itemHeightPx = with(androidx.compose.ui.platform.LocalDensity.current) { itemHeight.toPx() }
val totalHeight = itemHeight * visibleItemsCount
var currentOffsetPx by remember(selectedIndex) {
mutableFloatStateOf(-selectedIndex * itemHeightPx)
}
var lastTickIndex by remember { mutableIntStateOf(selectedIndex) }
val draggableState = rememberDraggableState { delta ->
val maxOffset = 0f
val minOffset = -(items.size - 1) * itemHeightPx
currentOffsetPx = (currentOffsetPx + delta).coerceIn(minOffset, maxOffset)
val activeIndex = (-currentOffsetPx / itemHeightPx).roundToInt().coerceIn(0, items.lastIndex)
if (activeIndex != lastTickIndex) {
sound.playWheelTick()
lastTickIndex = activeIndex
}
}
val shape = remember { RoundedCornerShape(6.dp) }
Box(
modifier = modifier
.fillMaxWidth()
.height(totalHeight)
.clip(shape)
.background(
Brush.verticalGradient(
listOf(
Color(0xFFE3E5E8),
Color(0xFFF7F8F9),
Color(0xFFFFFFFF),
Color(0xFFF7F8F9),
Color(0xFFE3E5E8)
)
)
)
.border(1.dp, Color(0xFF9E9E9E), shape)
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
onDragStopped = {
val targetIndex = (-currentOffsetPx / itemHeightPx).roundToInt().coerceIn(0, items.lastIndex)
currentOffsetPx = -targetIndex * itemHeightPx
onSelectedIndexChange(targetIndex)
}
),
contentAlignment = Alignment.Center
) {
// Render visible items with 3D cylindrical projection
items.forEachIndexed { index, text ->
val itemCenterY = (index * itemHeightPx) + currentOffsetPx + (totalHeight.value * 1.5f)
val diffFromCenterPx = (index * itemHeightPx) + currentOffsetPx
val normalizedDiff = (diffFromCenterPx / itemHeightPx)
if (abs(normalizedDiff) <= (visibleItemsCount / 2f + 1f)) {
val scale = (1f - (abs(normalizedDiff) * 0.10f)).coerceIn(0.75f, 1f)
val alpha = (1f - (abs(normalizedDiff) * 0.28f)).coerceIn(0.15f, 1f)
val rotationX = (-normalizedDiff * 22f).coerceIn(-65f, 65f)
val isCenter = abs(normalizedDiff) < 0.5f
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.offset { IntOffset(0, diffFromCenterPx.roundToInt()) }
.graphicsLayer {
this.scaleX = scale
this.scaleY = scale
this.alpha = alpha
this.rotationX = rotationX
cameraDistance = 12f * density
},
contentAlignment = Alignment.Center
) {
Text(
text = text,
style = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Medium,
fontSize = 20.sp,
color = if (isCenter) Color.Black else Color(0xFF4A4A4A),
shadow = if (isCenter) {
Shadow(
color = Color.White.copy(alpha = 0.9f),
offset = Offset(0f, 1.5f),
blurRadius = 0f
)
} else null
),
textAlign = TextAlign.Center
)
}
}
}
// Central Glass Magnifying Lens Overlay Bar
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.drawWithContent {
drawContent()
// Top glass reflection line
drawLine(
color = Color(0x664A7EBB),
start = Offset(0f, 0.5f),
end = Offset(size.width, 0.5f),
strokeWidth = 1.5f
)
// Bottom glass reflection line
drawLine(
color = Color(0x664A7EBB),
start = Offset(0f, size.height - 0.5f),
end = Offset(size.width, size.height - 0.5f),
strokeWidth = 1.5f
)
}
.background(
Brush.verticalGradient(
listOf(
Color(0x1A2A6EB5),
Color(0x082A6EB5),
Color(0x1A2A6EB5)
)
)
)
)
// Top & Bottom cylindrical shadow vignette
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
drawContent()
val vHeight = size.height * 0.35f
// Top shadow vignette
drawRect(
brush = Brush.verticalGradient(
listOf(Color(0x77000000), Color.Transparent),
startY = 0f,
endY = vHeight
),
size = Size(size.width, vHeight)
)
// Bottom shadow vignette
drawRect(
brush = Brush.verticalGradient(
listOf(Color.Transparent, Color(0x77000000)),
startY = size.height - vHeight,
endY = size.height
),
topLeft = Offset(0f, size.height - vHeight),
size = Size(size.width, vHeight)
)
}
)
}
}
@@ -0,0 +1,35 @@
package ai.heretek.pwnapple.components.table
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
/**
* Grouped rounded table card with 10dp radius and 1px border.
*/
@Composable
fun IosGroupedTable(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val colors = PwnappleTheme.colors
val shape = remember { RoundedCornerShape(10.dp) }
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(colors.cardBackground)
.border(1.dp, colors.cardBorder, shape),
content = content
)
}
@@ -0,0 +1,201 @@
package ai.heretek.pwnapple.components.table
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
enum class IosCellAccessory {
None,
DisclosureIndicator,
DetailDisclosureButton,
Checkmark
}
/**
* Standard iOS 6 table cell with leading icon, title, detail label,
* disclosure chevron, and interactive blue pressed state.
*/
@Composable
fun IosTableCell(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
detailText: String? = null,
leadingContent: @Composable (() -> Unit)? = null,
accessory: IosCellAccessory = IosCellAccessory.None,
accessoryContent: @Composable (() -> Unit)? = null,
showDivider: Boolean = true,
onClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val colors = PwnappleTheme.colors
val typography = PwnappleTheme.typography
val sound = PwnappleTheme.sound
val isPressed by interactionSource.collectIsPressedAsState()
val isInteractive = onClick != null
val indentPx = if (leadingContent != null) 48.dp else 14.dp
val pressedBrush = Brush.verticalGradient(
listOf(
Color(0xFF0A85F6),
Color(0xFF015FE6)
)
)
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 44.dp)
.then(
if (isPressed && isInteractive) {
Modifier.background(pressedBrush)
} else {
Modifier.background(colors.cardBackground)
}
)
.then(
if (isInteractive) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null
) {
sound.playButtonClick()
onClick?.invoke()
}
} else Modifier
)
.drawBehind {
if (showDivider) {
val startX = indentPx.toPx()
drawLine(
color = colors.cellDivider,
start = Offset(startX, size.height - 0.5f),
end = Offset(size.width, size.height - 0.5f),
strokeWidth = 1f
)
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Leading icon/content
if (leadingContent != null) {
Box(
modifier = Modifier.padding(end = 12.dp),
contentAlignment = Alignment.Center
) {
leadingContent()
}
}
// Title & optional subtitle
androidx.compose.foundation.layout.Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = typography.cellTitle.copy(
color = if (isPressed && isInteractive) Color.White else colors.bodyText
)
)
if (!subtitle.isNullOrEmpty()) {
Text(
text = subtitle,
style = typography.cellSubtitle.copy(
color = if (isPressed && isInteractive) Color.White.copy(alpha = 0.85f) else colors.tertiaryText
),
modifier = Modifier.padding(top = 2.dp)
)
}
}
// Detail text on right
if (!detailText.isNullOrEmpty()) {
Text(
text = detailText,
style = typography.cellSubtitle.copy(
color = if (isPressed && isInteractive) Color.White else colors.secondaryText
),
modifier = Modifier.padding(end = 6.dp)
)
}
// Custom accessory or built-in accessories
if (accessoryContent != null) {
accessoryContent()
} else {
when (accessory) {
IosCellAccessory.DisclosureIndicator -> {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Navigate",
tint = if (isPressed && isInteractive) Color.White else Color(0xFF7F7F7F),
modifier = Modifier.size(20.dp)
)
}
IosCellAccessory.Checkmark -> {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = if (isPressed && isInteractive) Color.White else Color(0xFF2E63B8),
modifier = Modifier.size(20.dp)
)
}
IosCellAccessory.DetailDisclosureButton -> {
Box(
modifier = Modifier
.size(22.dp)
.background(
Brush.verticalGradient(
listOf(Color(0xFF5A89C7), Color(0xFF2C5CA0))
),
shape = androidx.compose.foundation.shape.CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Detail",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
IosCellAccessory.None -> {}
}
}
}
}
}
@@ -0,0 +1,49 @@
package ai.heretek.pwnapple.components.table
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A section wrapper in an iOS 6 grouped table with letterpress debossed
* header and footer captions.
*/
@Composable
fun IosTableSection(
modifier: Modifier = Modifier,
headerText: String? = null,
footerText: String? = null,
content: @Composable ColumnScope.() -> Unit
) {
val typography = PwnappleTheme.typography
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
if (!headerText.isNullOrEmpty()) {
Text(
text = headerText,
style = typography.sectionHeader,
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 6.dp)
)
}
IosGroupedTable(content = content)
if (!footerText.isNullOrEmpty()) {
Text(
text = footerText,
style = typography.sectionFooter,
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 8.dp)
)
}
}
}
@@ -0,0 +1,64 @@
package ai.heretek.pwnapple.components.tabs
import ai.heretek.pwnapple.graphics.glossHighlight
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Authentic glossy red notification badge with white rim and drop shadow.
*/
@Composable
fun IosBadge(
count: String,
modifier: Modifier = Modifier
) {
val typography = PwnappleTheme.typography
val shape = remember { RoundedCornerShape(10.dp) }
Box(
modifier = modifier
.drawBehind {
drawRoundRect(
color = Color.Black.copy(alpha = 0.45f),
topLeft = Offset(0f, 1.5f),
size = size,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(10.dp.toPx(), 10.dp.toPx())
)
}
.clip(shape)
.background(
Brush.verticalGradient(
listOf(
Color(0xFFEE2D2D),
Color(0xFFBA0F0F)
)
)
)
.border(1.5.dp, Color.White, shape)
.glossHighlight(cornerRadius = 10.dp, glossAlpha = 0.5f)
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
.padding(horizontal = 5.dp, vertical = 1.dp),
contentAlignment = Alignment.Center
) {
Text(
text = count,
style = typography.badgeText
)
}
}
@@ -0,0 +1,144 @@
package ai.heretek.pwnapple.components.tabs
import ai.heretek.pwnapple.theme.PwnappleTheme
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
data class IosTabItem(
val title: String,
val icon: ImageVector,
val badge: String? = null
)
/**
* Skeuomorphic dark matte bottom tab bar with blue glow active indicators.
*/
@Composable
fun IosTabBar(
items: List<IosTabItem>,
selectedIndex: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val sound = PwnappleTheme.sound
val colors = PwnappleTheme.colors
val typography = PwnappleTheme.typography
Box(
modifier = modifier
.fillMaxWidth()
.height(49.dp)
.background(
Brush.verticalGradient(
listOf(
Color(0xFF222222),
Color(0xFF141414),
Color(0xFF080808)
)
)
)
.drawBehind {
// Top 1px border
drawLine(
color = Color.Black,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
strokeWidth = 1f
)
// Inner highlight line
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(0f, 1f),
end = Offset(size.width, 1f),
strokeWidth = 1f
)
}
) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
items.forEachIndexed { index, item ->
val isSelected = index == selectedIndex
val tintColor = if (isSelected) colors.tabBarActiveGlow else colors.tabBarInactive
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (index != selectedIndex) {
sound.playButtonClick()
onTabSelected(index)
}
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Icon(
imageVector = item.icon,
contentDescription = item.title,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
if (item.badge != null) {
IosBadge(
count = item.badge,
modifier = Modifier.offset(x = 10.dp, y = (-4).dp)
)
}
}
Text(
text = item.title,
style = typography.tabBarLabel.copy(
color = tintColor,
shadow = if (isSelected) {
Shadow(
color = Color(0x992EABFE),
offset = Offset(0f, 0f),
blurRadius = 4f
)
} else null
),
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
}
}
@@ -0,0 +1,53 @@
package ai.heretek.pwnapple.graphics
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
/**
* Shape producing the iconic iOS 6 chevron back-button outline:
* a pointed left arrow head merging seamlessly into a rounded rectangle.
*/
class ArrowBackShape(
private val arrowWidth: Dp = 14.dp,
private val cornerRadius: Dp = 4.dp
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path()
val w = size.width
val h = size.height
val aw = with(density) { arrowWidth.toPx() }.coerceAtMost(w * 0.45f)
val r = with(density) { cornerRadius.toPx() }.coerceAtMost(h / 2f)
// Start at left tip midpoint
path.moveTo(0f, h / 2f)
// Slant up to top edge
path.lineTo(aw, 0f)
// Horizontal top edge
path.lineTo(w - r, 0f)
// Top right corner arc
path.quadraticTo(w, 0f, w, r)
// Right edge
path.lineTo(w, h - r)
// Bottom right corner arc
path.quadraticTo(w, h, w - r, h)
// Horizontal bottom edge
path.lineTo(aw, h)
// Slant back down-left to tip midpoint
path.lineTo(0f, h / 2f)
path.close()
return Outline.Generic(path)
}
}
@@ -0,0 +1,54 @@
package ai.heretek.pwnapple.graphics
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Draws a 3D beveled skeuomorphic border with an outer stroke,
* top inner specular highlight line, and bottom inner shadow.
*/
fun Modifier.skeuoBevel(
cornerRadius: Dp = 4.dp,
borderColor: Color,
borderWidth: Dp = 1.dp,
highlightColor: Color = Color.White.copy(alpha = 0.35f),
shadowColor: Color = Color.Black.copy(alpha = 0.25f)
): Modifier = this.drawWithContent {
drawContent()
val width = size.width
val height = size.height
val rPx = cornerRadius.toPx()
val bWidthPx = borderWidth.toPx()
// Outer border stroke
drawRoundRect(
color = borderColor,
topLeft = Offset(bWidthPx / 2f, bWidthPx / 2f),
size = Size(width - bWidthPx, height - bWidthPx),
cornerRadius = CornerRadius(rPx, rPx),
style = Stroke(width = bWidthPx)
)
// Inner top highlight line
drawLine(
color = highlightColor,
start = Offset(rPx, bWidthPx + 0.5f),
end = Offset(width - rPx, bWidthPx + 0.5f),
strokeWidth = 1f
)
// Inner bottom shadow line
drawLine(
color = shadowColor,
start = Offset(rPx, height - bWidthPx - 0.5f),
end = Offset(width - rPx, height - bWidthPx - 0.5f),
strokeWidth = 1f
)
}
@@ -0,0 +1,109 @@
package ai.heretek.pwnapple.graphics
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Draws the iconic iOS 6 curved specular reflection over the top half of a component.
*/
fun Modifier.glossHighlight(
cornerRadius: Dp = 4.dp,
glossAlpha: Float = 0.45f
): Modifier = this.drawWithContent {
drawContent()
val width = size.width
val height = size.height
val rPx = cornerRadius.toPx()
val path = Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
left = 0f,
top = 0f,
right = width,
bottom = height,
topLeftCornerRadius = CornerRadius(rPx, rPx),
topRightCornerRadius = CornerRadius(rPx, rPx),
bottomLeftCornerRadius = CornerRadius(rPx, rPx),
bottomRightCornerRadius = CornerRadius(rPx, rPx)
)
)
}
clipPath(path) {
// Linear top gloss layer (top 50%)
val glossHeight = height * 0.50f
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = glossAlpha),
Color.White.copy(alpha = glossAlpha * 0.15f)
),
startY = 0f,
endY = glossHeight
),
topLeft = Offset.Zero,
size = Size(width, glossHeight)
)
// Specular highlight hairline on very top rim
drawLine(
color = Color.White.copy(alpha = glossAlpha * 0.9f),
start = Offset(rPx, 1f),
end = Offset(width - rPx, 1f),
strokeWidth = 1.5f
)
}
}
/**
* Renders an inner recessed shadow for inset controls (text fields, switch grooves, pressed buttons).
*/
fun Modifier.innerShadow(
cornerRadius: Dp = 4.dp,
shadowColor: Color = Color(0x55000000),
shadowHeight: Dp = 4.dp
): Modifier = this.drawWithContent {
drawContent()
val width = size.width
val height = size.height
val rPx = cornerRadius.toPx()
val sHeight = shadowHeight.toPx()
val path = Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
left = 0f,
top = 0f,
right = width,
bottom = height,
topLeftCornerRadius = CornerRadius(rPx, rPx),
topRightCornerRadius = CornerRadius(rPx, rPx),
bottomLeftCornerRadius = CornerRadius(rPx, rPx),
bottomRightCornerRadius = CornerRadius(rPx, rPx)
)
)
}
clipPath(path) {
// Inset shadow gradient on top edge
drawRect(
brush = Brush.verticalGradient(
colors = listOf(shadowColor, Color.Transparent),
startY = 0f,
endY = sHeight
),
topLeft = Offset.Zero,
size = Size(width, sHeight)
)
}
}
@@ -0,0 +1,67 @@
package ai.heretek.pwnapple.graphics
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Draws the classic vertical alternating pinstripe pattern from iOS 6 Settings.app.
*/
fun Modifier.pinstripeBackground(
baseColor: Color = Color(0xFFC5CCD4),
stripeColor: Color = Color(0xFFCBD2D8),
stripeWidth: Dp = 2.dp
): Modifier = this.drawBehind {
drawRect(color = baseColor)
val width = size.width
val height = size.height
val sWidthPx = stripeWidth.toPx()
val period = sWidthPx * 2
var x = 0f
while (x < width) {
drawLine(
color = stripeColor,
start = Offset(x + (sWidthPx / 2f), 0f),
end = Offset(x + (sWidthPx / 2f), height),
strokeWidth = sWidthPx
)
x += period
}
}
/**
* Draws the dark woven linen texture seen in the iOS 6 notification center,
* multitasking bar, and folder backgrounds.
*/
fun Modifier.darkLinenBackground(
baseColor: Color = Color(0xFF23272B),
crossColor: Color = Color(0xFF2C3238),
gridStep: Dp = 4.dp
): Modifier = this.drawBehind {
drawRect(color = baseColor)
val width = size.width
val height = size.height
val stepPx = gridStep.toPx()
// Diagonal weave lines
var offset = -height
while (offset < width) {
drawLine(
color = crossColor.copy(alpha = 0.5f),
start = Offset(offset, 0f),
end = Offset(offset + height, height),
strokeWidth = 1f
)
drawLine(
color = crossColor.copy(alpha = 0.5f),
start = Offset(offset + height, 0f),
end = Offset(offset, height),
strokeWidth = 1f
)
offset += stepPx
}
}
@@ -0,0 +1,22 @@
package ai.heretek.pwnapple.theme
import ai.heretek.pwnapple.audio.SoundPlayer
import androidx.compose.runtime.compositionLocalOf
class PwnappleAudioFeedback(
var enabled: Boolean = true
) {
fun playButtonClick() {
if (enabled) SoundPlayer.playButtonClick()
}
fun playWheelTick() {
if (enabled) SoundPlayer.playWheelTick()
}
fun playSwitchLatch() {
if (enabled) SoundPlayer.playSwitchLatch()
}
}
val LocalPwnappleSoundFeedback = compositionLocalOf { PwnappleAudioFeedback() }
@@ -0,0 +1,132 @@
package ai.heretek.pwnapple.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@Immutable
data class PwnappleColors(
// Canvas & Backgrounds
val canvasPinstripeBase: Color = Color(0xFFC5CCD4),
val canvasPinstripeStripe: Color = Color(0xFFCBD2D8),
val canvasDarkLinenBase: Color = Color(0xFF262B30),
val canvasDarkLinenStripe: Color = Color(0xFF30363C),
// Grouped Table Cards
val cardBackground: Color = Color(0xFFFFFFFF),
val cardBorder: Color = Color(0xFFABABAB),
val cellDivider: Color = Color(0xFFE2E2E2),
val cellPressed: Color = Color(0xFF0272EC),
val cellPressedText: Color = Color(0xFFFFFFFF),
// Navigation Bar
val navBarBlueTop: Color = Color(0xFF8A9EB9),
val navBarBlueBottom: Color = Color(0xFF5C7496),
val navBarBlackTop: Color = Color(0xFF4C4C4C),
val navBarBlackBottom: Color = Color(0xFF141414),
val navBarShadow: Color = Color(0xFF2A3442),
// Standard Buttons (Silver)
val buttonSilverTop: Color = Color(0xFFFCFCFC),
val buttonSilverMid: Color = Color(0xFFE9E9E9),
val buttonSilverBottom: Color = Color(0xFFD4D4D4),
val buttonSilverBorder: Color = Color(0xFFA5A5A5),
val buttonSilverText: Color = Color(0xFF222222),
// Action Buttons (Gloss Blue)
val buttonBlueTop: Color = Color(0xFF6E9BD7),
val buttonBlueMid: Color = Color(0xFF3F75C1),
val buttonBlueBottom: Color = Color(0xFF2B5FAA),
val buttonBlueBorder: Color = Color(0xFF1E4378),
val buttonBlueText: Color = Color(0xFFFFFFFF),
// Destructive Buttons (Gloss Red)
val buttonRedTop: Color = Color(0xFFEE5353),
val buttonRedMid: Color = Color(0xFFDA2A2A),
val buttonRedBottom: Color = Color(0xFFB81818),
val buttonRedBorder: Color = Color(0xFF851010),
val buttonRedText: Color = Color(0xFFFFFFFF),
// Switch & Slider (Cyan / Blue)
val switchOnTop: Color = Color(0xFF2D8CE6),
val switchOnBottom: Color = Color(0xFF0A62BD),
val switchOffTop: Color = Color(0xFFD6D6D6),
val switchOffBottom: Color = Color(0xFFF2F2F2),
val switchKnobTop: Color = Color(0xFFFFFFFF),
val switchKnobBottom: Color = Color(0xFFE4E4E4),
val switchKnobBorder: Color = Color(0xFFB0B0B0),
// Text & Headers
val headerText: Color = Color(0xFF4C566C),
val bodyText: Color = Color(0xFF000000),
val secondaryText: Color = Color(0xFF385487),
val tertiaryText: Color = Color(0xFF7F7F7F),
// Tab Bar
val tabBarBackground: Color = Color(0xFF121212),
val tabBarBorder: Color = Color(0xFF000000),
val tabBarActiveGlow: Color = Color(0xFF2EABFE),
val tabBarInactive: Color = Color(0xFF919191),
// Badges & Indicators
val badgeRedTop: Color = Color(0xFFEE2828),
val badgeRedBottom: Color = Color(0xFFB21010),
val badgeBorder: Color = Color(0xFFFFFFFF),
// Alert & Action Sheet
val alertBackgroundTop: Color = Color(0xFF233654),
val alertBackgroundBottom: Color = Color(0xFF111E33),
val alertBorder: Color = Color(0xFF8B9FB8)
) {
fun navBarBlueBrush(): Brush = Brush.verticalGradient(
listOf(navBarBlueTop, navBarBlueBottom)
)
fun navBarBlackBrush(): Brush = Brush.verticalGradient(
listOf(navBarBlackTop, navBarBlackBottom)
)
fun buttonSilverBrush(isPressed: Boolean = false): Brush {
return if (!isPressed) {
Brush.verticalGradient(
listOf(buttonSilverTop, buttonSilverMid, buttonSilverBottom)
)
} else {
Brush.verticalGradient(
listOf(buttonSilverBottom, buttonSilverMid, buttonSilverTop)
)
}
}
fun buttonBlueBrush(isPressed: Boolean = false): Brush {
return if (!isPressed) {
Brush.verticalGradient(
listOf(buttonBlueTop, buttonBlueMid, buttonBlueBottom)
)
} else {
Brush.verticalGradient(
listOf(buttonBlueBottom, buttonBlueMid, buttonBlueTop)
)
}
}
fun buttonRedBrush(isPressed: Boolean = false): Brush {
return if (!isPressed) {
Brush.verticalGradient(
listOf(buttonRedTop, buttonRedMid, buttonRedBottom)
)
} else {
Brush.verticalGradient(
listOf(buttonRedBottom, buttonRedMid, buttonRedTop)
)
}
}
fun switchOnBrush(): Brush = Brush.verticalGradient(
listOf(switchOnTop, switchOnBottom)
)
fun switchOffBrush(): Brush = Brush.verticalGradient(
listOf(switchOffTop, switchOffBottom)
)
}
@@ -0,0 +1,46 @@
package ai.heretek.pwnapple.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
val LocalPwnappleColors = staticCompositionLocalOf { PwnappleColors() }
val LocalPwnappleTypography = staticCompositionLocalOf { PwnappleTypography() }
@Composable
fun PwnappleTheme(
colors: PwnappleColors = PwnappleTheme.colors,
typography: PwnappleTypography = PwnappleTheme.typography,
soundFeedbackEnabled: Boolean = true,
content: @Composable () -> Unit
) {
val soundFeedback = remember(soundFeedbackEnabled) {
PwnappleAudioFeedback(enabled = soundFeedbackEnabled)
}
CompositionLocalProvider(
LocalPwnappleColors provides colors,
LocalPwnappleTypography provides typography,
LocalPwnappleSoundFeedback provides soundFeedback,
content = content
)
}
object PwnappleTheme {
val colors: PwnappleColors
@Composable
@ReadOnlyComposable
get() = LocalPwnappleColors.current
val typography: PwnappleTypography
@Composable
@ReadOnlyComposable
get() = LocalPwnappleTypography.current
val sound: PwnappleAudioFeedback
@Composable
@ReadOnlyComposable
get() = LocalPwnappleSoundFeedback.current
}
@@ -0,0 +1,109 @@
package ai.heretek.pwnapple.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@Immutable
data class PwnappleTypography(
val navTitle: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.65f),
offset = Offset(0f, -2f),
blurRadius = 0f
)
),
val sectionHeader: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color(0xFF4C566C),
shadow = Shadow(
color = Color.White.copy(alpha = 0.9f),
offset = Offset(0f, 2f),
blurRadius = 0f
)
),
val sectionFooter: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
color = Color(0xFF4C566C),
shadow = Shadow(
color = Color.White.copy(alpha = 0.9f),
offset = Offset(0f, 2f),
blurRadius = 0f
)
),
val cellTitle: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.Black
),
val cellSubtitle: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
color = Color(0xFF385487)
),
val buttonSilverText: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color(0xFF222222),
shadow = Shadow(
color = Color.White.copy(alpha = 0.8f),
offset = Offset(0f, 2f),
blurRadius = 0f
)
),
val buttonColorText: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.6f),
offset = Offset(0f, -2f),
blurRadius = 0f
)
),
val tabBarLabel: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
color = Color(0xFF919191)
),
val badgeText: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.6f),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
),
val switchLabel: TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.4f),
offset = Offset(0f, -1.5f),
blurRadius = 0f
)
)
)
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Pwnapple</string>
</resources>
@@ -0,0 +1,39 @@
package ai.heretek.pwnapple
import ai.heretek.pwnapple.graphics.ArrowBackShape
import ai.heretek.pwnapple.theme.PwnappleColors
import ai.heretek.pwnapple.theme.PwnappleTypography
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PwnappleCoreTest {
@Test
fun testColorsInitialization() {
val colors = PwnappleColors()
assertNotNull(colors.navBarBlueTop)
assertNotNull(colors.buttonSilverTop)
assertNotNull(colors.switchOnTop)
assertNotNull(colors.cardBackground)
assertNotNull(colors.navBarBlueBrush())
assertNotNull(colors.buttonSilverBrush(false))
assertNotNull(colors.buttonSilverBrush(true))
}
@Test
fun testTypographyDefaults() {
val typography = PwnappleTypography()
assertNotNull(typography.navTitle.shadow)
assertNotNull(typography.sectionHeader.shadow)
assertNotNull(typography.buttonSilverText.shadow)
assertTrue(typography.navTitle.fontSize.value > 15f)
}
@Test
fun testArrowBackShapeCreation() {
val shape = ArrowBackShape(arrowWidth = 14.dp, cornerRadius = 4.dp)
assertNotNull(shape)
}
}
+62
View File
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "ai.heretek.pwnapple.sample"
compileSdk = 35
defaultConfig {
applicationId = "ai.heretek.pwnapple.sample"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":pwnapple"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
}
View File
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,74 @@
package ai.heretek.pwnapple.sample
import ai.heretek.pwnapple.components.tabs.IosTabBar
import ai.heretek.pwnapple.components.tabs.IosTabItem
import ai.heretek.pwnapple.sample.screens.AlertsScreen
import ai.heretek.pwnapple.sample.screens.ControlsScreen
import ai.heretek.pwnapple.sample.screens.PickersScreen
import ai.heretek.pwnapple.sample.screens.SettingsScreen
import ai.heretek.pwnapple.theme.PwnappleTheme
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PwnappleTheme {
PwnappleShowcaseApp()
}
}
}
}
@Composable
fun PwnappleShowcaseApp() {
var selectedTab by remember { mutableIntStateOf(0) }
val tabItems = remember {
listOf(
IosTabItem(title = "Settings", icon = Icons.Default.Settings),
IosTabItem(title = "Controls", icon = Icons.Default.Widgets),
IosTabItem(title = "Pickers", icon = Icons.Default.DateRange),
IosTabItem(title = "Alerts", icon = Icons.Default.Notifications, badge = "1")
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFC5CCD4))
) {
Box(modifier = Modifier.weight(1f)) {
when (selectedTab) {
0 -> SettingsScreen()
1 -> ControlsScreen()
2 -> PickersScreen()
3 -> AlertsScreen()
}
}
IosTabBar(
items = tabItems,
selectedIndex = selectedTab,
onTabSelected = { selectedTab = it }
)
}
}
@@ -0,0 +1,122 @@
package ai.heretek.pwnapple.sample.screens
import ai.heretek.pwnapple.components.button.IosButton
import ai.heretek.pwnapple.components.button.IosButtonStyle
import ai.heretek.pwnapple.components.dialog.IosActionSheet
import ai.heretek.pwnapple.components.dialog.IosAlert
import ai.heretek.pwnapple.components.navigation.IosNavigationBar
import ai.heretek.pwnapple.components.table.IosTableSection
import ai.heretek.pwnapple.graphics.pinstripeBackground
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun AlertsScreen() {
var showSimpleAlert by remember { mutableStateOf(false) }
var showChoiceAlert by remember { mutableStateOf(false) }
var showActionSheet by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(title = "Alerts & Sheets")
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.verticalScroll(scrollState)
.padding(vertical = 12.dp)
) {
IosTableSection(
headerText = "MODAL DIALOGS",
footerText = "Classic translucent deep blue alerts with silver bevel and 3D specular sheen."
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
IosButton(
text = "Show Single-Button Alert",
onClick = { showSimpleAlert = true },
style = IosButtonStyle.Blue,
modifier = Modifier.fillMaxWidth()
)
IosButton(
text = "Show Two-Button Alert",
onClick = { showChoiceAlert = true },
style = IosButtonStyle.Silver,
modifier = Modifier.fillMaxWidth()
)
}
}
IosTableSection(
headerText = "ACTION SHEETS",
footerText = "Dark linen textured bottom sheet with destructive and cancel styling."
) {
Column(modifier = Modifier.padding(14.dp)) {
IosButton(
text = "Trigger Action Sheet",
onClick = { showActionSheet = true },
style = IosButtonStyle.Red,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// Active Dialogs / Overlays
if (showSimpleAlert) {
IosAlert(
title = "Cannot Connect to iTunes Store",
message = "An error occurred while connecting to the iTunes Store. Please check your network connection and try again.",
onDismissRequest = { showSimpleAlert = false },
confirmButtonText = "OK"
)
}
if (showChoiceAlert) {
IosAlert(
title = "Restore Network Settings",
message = "This will reset all network settings, returning them to factory defaults. Continue?",
onDismissRequest = { showChoiceAlert = false },
dismissButtonText = "Cancel",
confirmButtonText = "Reset"
)
}
if (showActionSheet) {
IosActionSheet(
title = "Are you sure you want to proceed?",
destructiveText = "Delete All Content",
onDestructiveClick = { showActionSheet = false },
actionButtons = listOf(
"Back Up to iCloud" to { showActionSheet = false },
"Archive Locally" to { showActionSheet = false }
),
cancelText = "Cancel",
onDismissRequest = { showActionSheet = false }
)
}
}
}
@@ -0,0 +1,178 @@
package ai.heretek.pwnapple.sample.screens
import ai.heretek.pwnapple.components.button.IosButton
import ai.heretek.pwnapple.components.button.IosButtonSize
import ai.heretek.pwnapple.components.button.IosButtonStyle
import ai.heretek.pwnapple.components.button.IosSegmentedControl
import ai.heretek.pwnapple.components.indicator.IosActivityIndicator
import ai.heretek.pwnapple.components.indicator.IosProgressBar
import ai.heretek.pwnapple.components.input.IosSlider
import ai.heretek.pwnapple.components.input.IosTextField
import ai.heretek.pwnapple.components.navigation.IosNavigationBar
import ai.heretek.pwnapple.components.table.IosGroupedTable
import ai.heretek.pwnapple.components.table.IosTableSection
import ai.heretek.pwnapple.graphics.pinstripeBackground
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun ControlsScreen() {
val scrollState = rememberScrollState()
var selectedSegment by remember { mutableIntStateOf(0) }
var textInput by remember { mutableStateOf("Steve Jobs") }
var searchInput by remember { mutableStateOf("") }
var progressVal by remember { mutableFloatStateOf(0.65f) }
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(title = "Controls")
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.verticalScroll(scrollState)
.padding(vertical = 12.dp)
) {
// Segmented Control Section
IosTableSection(headerText = "SEGMENTED CONTROLS") {
Box(modifier = Modifier.padding(14.dp)) {
IosSegmentedControl(
items = listOf("Map", "Satellite", "Hybrid"),
selectedIndex = selectedSegment,
onItemSelected = { selectedSegment = it }
)
}
}
// Buttons Section
IosTableSection(headerText = "SKEUOMORPHIC BUTTONS") {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
IosButton(
text = "Standard Silver Button",
onClick = {},
style = IosButtonStyle.Silver,
modifier = Modifier.fillMaxWidth()
)
IosButton(
text = "Action Blue Button",
onClick = {},
style = IosButtonStyle.Blue,
modifier = Modifier.fillMaxWidth()
)
IosButton(
text = "Destructive Red Button",
onClick = {},
style = IosButtonStyle.Red,
modifier = Modifier.fillMaxWidth()
)
IosButton(
text = "Dark Slate Button",
onClick = {},
style = IosButtonStyle.Black,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IosButton(
text = "Small Silver",
onClick = {},
style = IosButtonStyle.Silver,
size = IosButtonSize.Small,
modifier = Modifier.weight(1f)
)
IosButton(
text = "Small Blue",
onClick = {},
style = IosButtonStyle.Blue,
size = IosButtonSize.Small,
modifier = Modifier.weight(1f)
)
}
}
}
// Text Inputs Section
IosTableSection(headerText = "TEXT FIELDS") {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
IosTextField(
value = textInput,
onValueChange = { textInput = it },
placeholder = "Name"
)
IosTextField(
value = searchInput,
onValueChange = { searchInput = it },
placeholder = "Search Contacts",
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = Color(0xFF7F7F7F)
)
}
)
}
}
// Progress & Indicators
IosTableSection(headerText = "PROGRESS & SPINNERS") {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
IosProgressBar(progress = progressVal)
IosSlider(
value = progressVal,
onValueChange = { progressVal = it }
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
IosActivityIndicator(size = 22.dp)
IosActivityIndicator(size = 32.dp)
IosActivityIndicator(size = 42.dp)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,92 @@
package ai.heretek.pwnapple.sample.screens
import ai.heretek.pwnapple.components.navigation.IosNavigationBar
import ai.heretek.pwnapple.components.picker.IosPickerWheel
import ai.heretek.pwnapple.components.table.IosTableCell
import ai.heretek.pwnapple.components.table.IosTableSection
import ai.heretek.pwnapple.graphics.pinstripeBackground
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PickersScreen() {
val devices = listOf(
"iPhone 3GS",
"iPhone 4",
"iPhone 4S",
"iPhone 5",
"iPod touch (4th gen)",
"iPod touch (5th gen)",
"iPad (3rd gen)",
"iPad (4th gen)",
"iPad mini"
)
val months = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
)
var selectedDeviceIndex by remember { mutableIntStateOf(3) }
var selectedMonthIndex by remember { mutableIntStateOf(8) }
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(title = "Pickers")
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.verticalScroll(scrollState)
.padding(vertical = 12.dp)
) {
IosTableSection(headerText = "SELECTED VALUE") {
IosTableCell(
title = "Target Device",
detailText = devices[selectedDeviceIndex]
)
IosTableCell(
title = "Release Month",
detailText = months[selectedMonthIndex],
showDivider = false
)
}
IosTableSection(headerText = "DEVICE DRUM WHEEL") {
Box(modifier = Modifier.padding(14.dp)) {
IosPickerWheel(
items = devices,
selectedIndex = selectedDeviceIndex,
onSelectedIndexChange = { selectedDeviceIndex = it }
)
}
}
IosTableSection(headerText = "MONTH DRUM WHEEL") {
Box(modifier = Modifier.padding(14.dp)) {
IosPickerWheel(
items = months,
selectedIndex = selectedMonthIndex,
onSelectedIndexChange = { selectedMonthIndex = it }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,327 @@
package ai.heretek.pwnapple.sample.screens
import ai.heretek.pwnapple.components.input.IosSlider
import ai.heretek.pwnapple.components.input.IosSwitch
import ai.heretek.pwnapple.components.navigation.IosBackButton
import ai.heretek.pwnapple.components.navigation.IosNavigationBar
import ai.heretek.pwnapple.components.table.IosCellAccessory
import ai.heretek.pwnapple.components.table.IosTableCell
import ai.heretek.pwnapple.components.table.IosTableSection
import ai.heretek.pwnapple.graphics.pinstripeBackground
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AirplanemodeActive
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.DoNotDisturb
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
enum class SettingsSubscreen {
Main,
Wifi,
Sounds,
About
}
@Composable
fun SettingsScreen() {
var subscreen by remember { mutableStateOf(SettingsSubscreen.Main) }
when (subscreen) {
SettingsSubscreen.Main -> MainSettings(
onNavigateWifi = { subscreen = SettingsSubscreen.Wifi },
onNavigateSounds = { subscreen = SettingsSubscreen.Sounds },
onNavigateAbout = { subscreen = SettingsSubscreen.About }
)
SettingsSubscreen.Wifi -> WifiSubscreen(onBack = { subscreen = SettingsSubscreen.Main })
SettingsSubscreen.Sounds -> SoundsSubscreen(onBack = { subscreen = SettingsSubscreen.Main })
SettingsSubscreen.About -> AboutSubscreen(onBack = { subscreen = SettingsSubscreen.Main })
}
}
@Composable
private fun MainSettings(
onNavigateWifi: () -> Unit,
onNavigateSounds: () -> Unit,
onNavigateAbout: () -> Unit
) {
var airplaneMode by remember { mutableStateOf(false) }
var doNotDisturb by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(title = "Settings")
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.verticalScroll(scrollState)
.padding(vertical = 12.dp)
) {
// Connectivity Group
IosTableSection {
IosTableCell(
title = "Airplane Mode",
leadingContent = { SettingsIcon(Icons.Default.AirplanemodeActive, Color(0xFFF28F16)) },
accessoryContent = {
IosSwitch(
checked = airplaneMode,
onCheckedChange = { airplaneMode = it }
)
}
)
IosTableCell(
title = "Wi-Fi",
detailText = "Heretek_5G",
leadingContent = { SettingsIcon(Icons.Default.Wifi, Color(0xFF007AFE)) },
accessory = IosCellAccessory.DisclosureIndicator,
onClick = onNavigateWifi
)
IosTableCell(
title = "Bluetooth",
detailText = "On",
leadingContent = { SettingsIcon(Icons.Default.Bluetooth, Color(0xFF007AFE)) },
accessory = IosCellAccessory.DisclosureIndicator,
onClick = {}
)
IosTableCell(
title = "Do Not Disturb",
leadingContent = { SettingsIcon(Icons.Default.DoNotDisturb, Color(0xFF5756CE)) },
accessoryContent = {
IosSwitch(
checked = doNotDisturb,
onCheckedChange = { doNotDisturb = it }
)
},
showDivider = false
)
}
// General & Preferences Group
IosTableSection(
footerText = "When Do Not Disturb is enabled, calls and alerts that arrive while locked will be silenced."
) {
IosTableCell(
title = "Notifications",
leadingContent = { SettingsIcon(Icons.Default.Notifications, Color(0xFFFD3B30)) },
accessory = IosCellAccessory.DisclosureIndicator,
onClick = {}
)
IosTableCell(
title = "General",
leadingContent = { SettingsIcon(Icons.Default.Settings, Color(0xFF8E8E93)) },
accessory = IosCellAccessory.DisclosureIndicator,
onClick = onNavigateAbout
)
IosTableCell(
title = "Sounds",
leadingContent = { SettingsIcon(Icons.AutoMirrored.Filled.VolumeUp, Color(0xFFFD3B30)) },
accessory = IosCellAccessory.DisclosureIndicator,
onClick = onNavigateSounds
)
IosTableCell(
title = "Brightness & Wallpaper",
leadingContent = { SettingsIcon(Icons.Default.Wallpaper, Color(0xFF007AFE)) },
accessory = IosCellAccessory.DisclosureIndicator,
showDivider = false,
onClick = {}
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun WifiSubscreen(onBack: () -> Unit) {
var wifiEnabled by remember { mutableStateOf(true) }
var selectedNetwork by remember { mutableStateOf("Heretek_5G") }
val networks = listOf("Heretek_5G", "Cupertino_Guest", "Pwnapple_Lab", "Infinite_Loop")
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(
title = "Wi-Fi",
navigationIcon = { IosBackButton(text = "Settings", onClick = onBack) }
)
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.padding(vertical = 12.dp)
) {
IosTableSection {
IosTableCell(
title = "Wi-Fi",
accessoryContent = {
IosSwitch(
checked = wifiEnabled,
onCheckedChange = { wifiEnabled = it }
)
},
showDivider = false
)
}
if (wifiEnabled) {
IosTableSection(headerText = "CHOOSE A NETWORK...") {
networks.forEachIndexed { index, net ->
val isCurrent = net == selectedNetwork
IosTableCell(
title = net,
accessory = if (isCurrent) IosCellAccessory.Checkmark else IosCellAccessory.None,
showDivider = index < networks.lastIndex,
onClick = { selectedNetwork = net }
)
}
}
}
}
}
}
@Composable
private fun SoundsSubscreen(onBack: () -> Unit) {
var ringerVolume by remember { mutableFloatStateOf(0.75f) }
var vibrateOnRing by remember { mutableStateOf(true) }
var vibrateOnSilent by remember { mutableStateOf(true) }
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(
title = "Sounds",
navigationIcon = { IosBackButton(text = "Settings", onClick = onBack) }
)
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.padding(vertical = 12.dp)
) {
IosTableSection(headerText = "VIBRATE") {
IosTableCell(
title = "Vibrate on Ring",
accessoryContent = {
IosSwitch(checked = vibrateOnRing, onCheckedChange = { vibrateOnRing = it })
}
)
IosTableCell(
title = "Vibrate on Silent",
accessoryContent = {
IosSwitch(checked = vibrateOnSilent, onCheckedChange = { vibrateOnSilent = it })
},
showDivider = false
)
}
IosTableSection(
headerText = "RINGER AND ALERTS",
footerText = "Adjust ringer volume and notification sound levels."
) {
Box(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
IosSlider(
value = ringerVolume,
onValueChange = { ringerVolume = it }
)
}
}
IosTableSection(headerText = "SOUNDS") {
IosTableCell(
title = "Ringtone",
detailText = "Marimba",
accessory = IosCellAccessory.DisclosureIndicator,
onClick = {}
)
IosTableCell(
title = "Text Tone",
detailText = "Tri-tone",
accessory = IosCellAccessory.DisclosureIndicator,
showDivider = false,
onClick = {}
)
}
}
}
}
@Composable
private fun AboutSubscreen(onBack: () -> Unit) {
Column(modifier = Modifier.fillMaxSize()) {
IosNavigationBar(
title = "About",
navigationIcon = { IosBackButton(text = "General", onClick = onBack) }
)
Column(
modifier = Modifier
.fillMaxSize()
.pinstripeBackground()
.padding(vertical = 12.dp)
) {
IosTableSection {
IosTableCell(title = "Name", detailText = "Pwnapple Phone")
IosTableCell(title = "Network", detailText = "Heretek Wireless")
IosTableCell(title = "Songs", detailText = "1,337")
IosTableCell(title = "Videos", detailText = "42")
IosTableCell(title = "Photos", detailText = "856")
IosTableCell(title = "Capacity", detailText = "64 GB")
IosTableCell(title = "Available", detailText = "48.2 GB")
IosTableCell(title = "Version", detailText = "6.1.6 (10B500)")
IosTableCell(title = "Model", detailText = "MD298LL/A", showDivider = false)
}
}
}
}
@Composable
private fun SettingsIcon(icon: ImageVector, tint: Color) {
Box(
modifier = Modifier
.size(28.dp)
.clip(RoundedCornerShape(6.dp))
.background(
Brush.verticalGradient(
listOf(tint, tint.copy(alpha = 0.82f))
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Pwnapple</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "pwnapple"
include(":pwnapple")
include(":sample")