sonodrill

Sonodrill, app iOS/Android d’entraînement musical (repères, boucles, vitesse), Kotlin Multiplatform + Compose
git clone https://git.schurger.org/sonodrill.git
Log | Files | Refs

commit d127d7e444890535ce2229821e55b166d168ecbc
parent ea0bf38748c9ee3cb79d6494ff52015129539483
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  1 Apr 2026 14:22:23 -0400

Add IAP tip, skip zones, delay timer, rename, mark labels, and Fastlane fixes

- In-app purchase tip ($5) via StoreKit 2 with heart icon toggle
- Skip zones: mark a region to auto-skip during playback (red tint)
- Play delay timer (1-10s countdown before playback starts)
- Rename file by tapping the title
- Mark time labels at centisecond precision on the timeline
- Cleanup skip zones when marks are deleted
- Speed/delay buttons moved to transport row
- Fastlane updated for Xcode 26 compatibility
- IAP setup documentation in IAP_SETUP.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
AIAP_SETUP.md | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt | 2++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt | 5+++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt | 2++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt | 2--
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt | 6++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt | 3+++
AcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt | 8++++++++
MiosApp/ContentView.swift | 1+
AiosApp/ExportOptions.plist | 14++++++++++++++
MiosApp/Info.plist | 1+
AiosApp/TipManager.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MiosApp/fastlane/Fastfile | 75+++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
MiosApp/iosApp.xcodeproj/project.pbxproj | 65++++++++++++++++++++++++++++++++++++++---------------------------
MiosApp/project.yml | 5++++-
18 files changed, 609 insertions(+), 100 deletions(-)

diff --git a/IAP_SETUP.md b/IAP_SETUP.md @@ -0,0 +1,88 @@ +# In-App Purchase Setup - Sonodrill Tip ($5) + +Product ID: `ubi2.sonodrill.tip` +Type: Non-Consumable (one-time purchase) +Price: Tier 3 ($4.99) + +## 1. Agreements (prerequisite) + +Go to https://appstoreconnect.apple.com > **Agreements, Tax, and Banking** + +- Accept the **Paid Apps** agreement +- Fill in banking info, tax info, and contact info +- Status must be **Active** before IAP works + +## 2. Create the IAP product + +1. https://appstoreconnect.apple.com > **My Apps** > **Sonodrill** +2. Sidebar: **Monetization** > **In-App Purchases** > **+** +3. Fill in: + - **Type**: Non-Consumable + - **Reference Name**: `Tip $5` + - **Product ID**: `ubi2.sonodrill.tip` +4. **Price**: Select price tier = $4.99 (Tier 3) +5. Add **Localization** (English): + - **Display Name**: `Support Sonodrill` + - **Description**: `A friendly tip to support independent development` +6. Add a **Review Screenshot** (screenshot of the tip dialog in the app) +7. Save + +## 3. Sandbox testing + +### Create a sandbox tester +1. https://appstoreconnect.apple.com > **Users and Access** > **Sandbox** > **Testers** +2. Create a test account (use a throwaway email, not a real Apple ID) + +### Test on device +1. On iPhone: **Settings** > **App Store** > scroll to bottom > **Sandbox Account** +2. Sign in with the sandbox tester account +3. Launch Sonodrill, tap the heart, tap "Tip $5" +4. Sandbox purchases are free and instant + +### Test on simulator (StoreKit Configuration file) +1. In Xcode: **File** > **New** > **File** > search **StoreKit Configuration File** +2. Name it `Sonodrill.storekit`, save in `iosApp/` +3. Add a product: + - Type: Non-Consumable + - Product ID: `ubi2.sonodrill.tip` + - Price: $4.99 + - Display Name: `Support Sonodrill` +4. In Xcode: **Product** > **Scheme** > **Edit Scheme** > **Run** > **Options** +5. Set **StoreKit Configuration** to `Sonodrill.storekit` +6. Now purchases work in the simulator without App Store Connect + +## 4. Submit for review + +The IAP must be submitted alongside an app update that uses it. +When submitting the app for review: +1. Make sure the IAP status is **Ready to Submit** +2. In the app version submission, attach the IAP under **In-App Purchases** +3. Apple will review both the app and the IAP together + +## Code reference + +- Swift StoreKit logic: `iosApp/TipManager.swift` +- Kotlin provider interface: `composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt` +- Registration: `ContentView.swift` calls `Main_iosKt.register_tip_provider()` +- UI: `library_screen.kt` - heart button + dialog + +## Environment variables for Fastlane + +``` +export UBI2_APPLE_ID=jean@ubi2.ca +export UBI2_ASC_KEY_ID=VR2XG8P2B3 +export UBI2_ASC_ISSUER_ID=cef4d644-a166-48c6-85fc-69a1899078d6 +export UBI2_ASC_KEY_PATH=/Users/jean.schurger/.appstoreconnect/AuthKey_VR2XG8P2B3.p8 +``` + +## Fastlane release + +``` +cd iosApp && fastlane release +``` + +Note: Xcode 26 beta has a bug with `exportArchive` ("Copy failed"). +Workaround: use Xcode Organizer manually: +1. `cd iosApp && fastlane release` (will archive successfully, export will fail) +2. Open Xcode > **Window** > **Organizer** +3. Select the Sonodrill archive > **Distribute App** > **App Store Connect** diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt @@ -6,3 +6,5 @@ actual fun file_exists(path: String): Boolean = java.io.File(path).exists() actual fun open_url(url: String) {} actual fun get_play_count(): Int = 0 actual fun increment_play_count() {} +actual fun has_tipped(): Boolean = false +actual fun purchase_tip() {} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt @@ -24,3 +24,8 @@ data class LoopState( val mark_b: MarkInfo? = null, val enabled: Boolean = false, ) + +data class SkipZone( + val start_ms: Long, + val end_ms: Long, +) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt @@ -6,3 +6,5 @@ expect fun file_exists(path: String): Boolean expect fun open_url(url: String) expect fun get_play_count(): Int expect fun increment_play_count() +expect fun has_tipped(): Boolean +expect fun purchase_tip() diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt @@ -9,7 +9,6 @@ import ubi2.sonodrill.audio.AudioPlayer import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.model.AudioFileInfo import ubi2.sonodrill.model.file_exists -import ubi2.sonodrill.model.get_play_count import ubi2.sonodrill.ui.library.LibraryScreen import ubi2.sonodrill.ui.player.PlayerScreen import ubi2.sonodrill.ui.player.PlayerViewModel @@ -50,7 +49,6 @@ fun SonodrillApp(player: AudioPlayer, repo: AudioRepository, on_import: (on_done }, on_import = { on_import { refresh_files() } }, on_delete = { id -> repo.delete_file(id); refresh_files() }, - show_kofi = get_play_count() > 5, ) Screen.PLAYER -> player_vm?.let { vm -> PlayerScreen(vm = vm, on_back = { vm.release(); player_vm = null; refresh_files(); screen = Screen.LIBRARY }) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt @@ -16,7 +16,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ubi2.sonodrill.model.AudioFileInfo -import ubi2.sonodrill.model.open_url +import ubi2.sonodrill.model.has_tipped +import ubi2.sonodrill.model.purchase_tip import ubi2.sonodrill.ui.player.format_time @OptIn(ExperimentalMaterial3Api::class) @@ -26,20 +27,26 @@ fun LibraryScreen( on_file_click: (AudioFileInfo) -> Unit, on_import: () -> Unit, on_delete: (String) -> Unit, - show_kofi: Boolean = false, ) { var search by remember { mutableStateOf("") } val filtered = if (search.isBlank()) files else files.filter { it.title.contains(search, ignoreCase = true) } + var show_tip_dialog by remember { mutableStateOf(false) } + var tipped by remember { mutableStateOf(has_tipped()) } Scaffold( topBar = { TopAppBar( title = { Text("Sonodrill") }, actions = { - if (show_kofi) { - IconButton(onClick = { open_url("https://ko-fi.com/jeanschurger") }) { - Icon(Icons.Default.Favorite, "Support on Ko-fi", tint = MaterialTheme.colorScheme.error) - } + IconButton(onClick = { + tipped = has_tipped() + show_tip_dialog = true + }) { + Icon( + if (tipped) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + "Support", + tint = MaterialTheme.colorScheme.error, + ) } }, ) @@ -69,6 +76,46 @@ fun LibraryScreen( } } } + + if (show_tip_dialog) { + AlertDialog( + onDismissRequest = { show_tip_dialog = false }, + icon = { + Icon( + if (tipped) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp), + ) + }, + title = { + Text(if (tipped) "Thank you!" else "Support Sonodrill") + }, + text = { + Text( + if (tipped) + "Thanks for your generous support! Your contribution helps keep Sonodrill alive and improving." + else + "Sonodrill is made with love by an independent developer. If you enjoy using it, consider leaving a friendly tip to support its development." + ) + }, + confirmButton = { + if (tipped) { + TextButton(onClick = { show_tip_dialog = false }) { Text("Close") } + } else { + Button(onClick = { + purchase_tip() + show_tip_dialog = false + }) { Text("Tip \$5") } + } + }, + dismissButton = { + if (!tipped) { + TextButton(onClick = { show_tip_dialog = false }) { Text("Maybe later") } + } + }, + ) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt @@ -2,9 +2,12 @@ package ubi2.sonodrill.ui.player import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,6 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ubi2.sonodrill.model.LoopState import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.SkipZone @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,13 +41,25 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { val loop_state by vm.loop_state.collectAsState() val file by vm.current_file.collectAsState() val waveform by vm.waveform.collectAsState() + val skip_zones by vm.skip_zones.collectAsState() + + val play_delay_s by vm.play_delay_s.collectAsState() + val play_delay_enabled by vm.play_delay_enabled.collectAsState() + val countdown by vm.countdown.collectAsState() var show_speed_sheet by remember { mutableStateOf(false) } + var show_rename by remember { mutableStateOf(false) } + var show_delay_picker by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( - title = { Text(file?.title ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + title = { + Text( + file?.title ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { show_rename = true }, + ) + }, navigationIcon = { IconButton(onClick = on_back) { Icon(Icons.Default.ArrowBack, "Back") } }, @@ -61,6 +77,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { marks = marks, loop_state = loop_state, waveform = waveform, + skip_zones = skip_zones, on_seek = { tap_ms -> vm.seek(tap_ms) // If mark loop is active and tap is outside current loop, re-loop around tap position @@ -87,30 +104,15 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { Spacer(Modifier.height(8.dp)) - // Transport controls with loop buttons + // Transport controls Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - val mark_loop_active = loop_state.enabled && loop_state.mark_a != null && loop_state.mark_a!!.id != "_full_a" - val full_loop_active = loop_state.enabled && loop_state.mark_a?.id == "_full_a" - - // Mark loop toggle - if (mark_loop_active) { - FilledIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { - Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) - Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(12.dp)) - } - } - } else { - OutlinedIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { - Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) - Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(12.dp)) - } - } + // Speed button + OutlinedButton(onClick = { show_speed_sheet = true }) { + Text("${format_speed(speed)}x") } IconButton(onClick = { vm.prev_mark() }) { @@ -120,16 +122,44 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { onClick = { vm.toggle_playback() }, modifier = Modifier.size(64.dp), ) { - Icon( - if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (playing) "Pause" else "Play", - modifier = Modifier.size(32.dp), - ) + if (countdown > 0) { + Text("$countdown", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } else { + Icon( + if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (playing) "Pause" else "Play", + modifier = Modifier.size(32.dp), + ) + } } IconButton(onClick = { vm.next_mark() }) { Icon(Icons.Default.SkipNext, "Next mark") } + // Delay button + OutlinedButton(onClick = { + if (play_delay_enabled) vm.toggle_play_delay() + else show_delay_picker = true + }) { + Icon(Icons.Default.Timer, null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text( + if (play_delay_enabled) "${play_delay_s}s" else "off", + fontSize = 12.sp, + color = if (play_delay_enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } + } + + // Action buttons row + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + val mark_loop_active = loop_state.enabled && loop_state.mark_a != null && loop_state.mark_a!!.id != "_full_a" + val full_loop_active = loop_state.enabled && loop_state.mark_a?.id == "_full_a" + // Full loop toggle if (full_loop_active) { FilledIconButton(onClick = { vm.toggle_full_loop() }) { @@ -140,17 +170,24 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { Icon(Icons.Default.Repeat, null, modifier = Modifier.size(22.dp)) } } - } - // Action buttons row - Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp).padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton(onClick = { show_speed_sheet = true }) { - Text("${format_speed(speed)}x") + // Mark loop toggle + if (mark_loop_active) { + FilledIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { + Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) + Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(12.dp)) + } + } + } else { + OutlinedIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { + Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) + Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(12.dp)) + } + } } + OutlinedIconButton(onClick = { vm.add_mark() }) { Icon(Icons.Default.BookmarkAdd, "Add mark", modifier = Modifier.size(22.dp)) } @@ -166,6 +203,17 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(14.dp)) } } + // Add/remove skip zone + val in_skip = skip_zones.any { position >= it.start_ms && position < it.end_ms } + if (in_skip) { + FilledIconButton(onClick = { vm.remove_skip_zone_at_position() }) { + Icon(Icons.Default.NotInterested, "Remove skip zone", modifier = Modifier.size(22.dp)) + } + } else { + OutlinedIconButton(onClick = { vm.add_skip_zone() }, enabled = marks.size >= 2) { + Icon(Icons.Default.NotInterested, "Add skip zone", modifier = Modifier.size(22.dp)) + } + } } Spacer(Modifier.height(8.dp)) @@ -179,6 +227,58 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { Spacer(Modifier.height(32.dp)) } } + + // Rename dialog + if (show_rename) { + var name by remember { mutableStateOf(file?.title ?: "") } + AlertDialog( + onDismissRequest = { show_rename = false }, + title = { Text("Rename") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { vm.rename_file(name); show_rename = false }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { show_rename = false }) { Text("Cancel") } + }, + ) + } + + // Delay picker dialog + if (show_delay_picker) { + val delays = listOf(1, 2, 3, 5, 10) + AlertDialog( + onDismissRequest = { show_delay_picker = false }, + title = { Text("Play delay") }, + text = { + Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth()) { + delays.forEach { d -> + FilterChip( + selected = play_delay_s == d, + onClick = { vm.set_play_delay(d) }, + label = { Text("${d}s") }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + if (!play_delay_enabled) vm.toggle_play_delay() + show_delay_picker = false + }) { Text("Enable") } + }, + dismissButton = { + TextButton(onClick = { show_delay_picker = false }) { Text("Cancel") } + }, + ) + } } private const val BASE_ROWS = 6 @@ -191,6 +291,7 @@ private fun ProgressTimeline( marks: List<MarkInfo>, loop_state: LoopState, waveform: FloatArray, + skip_zones: List<SkipZone>, on_seek: (Long) -> Unit, ) { val primary = MaterialTheme.colorScheme.primary @@ -198,6 +299,7 @@ private fun ProgressTimeline( val on_surface = MaterialTheme.colorScheme.onSurface val mark_color = Color(0xFFFF6B35) val loop_color = MaterialTheme.colorScheme.primaryContainer + val skip_color = Color(0x33FF0000) var zoom by remember { mutableFloatStateOf(1f) } val rows = (BASE_ROWS * zoom).toInt().coerceAtLeast(BASE_ROWS) @@ -240,7 +342,8 @@ private fun ProgressTimeline( position_ms = position_ms, duration_ms = duration_ms, marks = marks, loop_state = loop_state, waveform = waveform, on_seek = on_seek, primary = primary, surface_variant = surface_variant, on_surface = on_surface, - mark_color = mark_color, loop_color = loop_color, + mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, + skip_zones = skip_zones, modifier = Modifier.fillMaxWidth().height(ROW_HEIGHT_DP.dp), ) } @@ -257,7 +360,8 @@ private fun ProgressTimeline( position_ms = position_ms, duration_ms = duration_ms, marks = marks, loop_state = loop_state, waveform = waveform, on_seek = on_seek, primary = primary, surface_variant = surface_variant, on_surface = on_surface, - mark_color = mark_color, loop_color = loop_color, + mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, + skip_zones = skip_zones, modifier = Modifier.fillMaxWidth().weight(1f), ) } @@ -272,7 +376,8 @@ private fun TimelineRow( position_ms: Long, duration_ms: Long, marks: List<MarkInfo>, loop_state: LoopState, waveform: FloatArray, on_seek: (Long) -> Unit, primary: Color, surface_variant: Color, on_surface: Color, - mark_color: Color, loop_color: Color, + mark_color: Color, loop_color: Color, skip_color: Color, + skip_zones: List<SkipZone>, modifier: Modifier, ) { val row_start_ms = row * row_duration @@ -319,6 +424,19 @@ private fun TimelineRow( } } + // Skip zones (red tint) + for (zone in skip_zones) { + val zs = ((zone.start_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + val ze = ((zone.end_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + if (ze > zs && zone.start_ms < row_end_ms && zone.end_ms > row_start_ms) { + drawRect( + color = skip_color, + topLeft = Offset(zs * w, 0f), + size = androidx.compose.ui.geometry.Size((ze - zs) * w, h), + ) + } + } + // Waveform or simple progress val row_samples = wave_end - wave_start if (row_samples > 0) { @@ -367,6 +485,25 @@ private fun TimelineRow( .background(surface_variant.copy(alpha = 0.7f), RoundedCornerShape(3.dp)) .padding(horizontal = 3.dp, vertical = 1.dp), ) + + // Mark time labels + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val total_width = maxWidth + for (mark in row_marks) { + val mark_frac = (mark.position_ms - row_start_ms) / (row_end_ms - row_start_ms) + val offset_x = total_width * mark_frac + Text( + text = format_time_centis(mark.position_ms), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFCC3300), + modifier = Modifier + .offset(x = (offset_x - 22.dp).coerceAtLeast(0.dp), y = 1.dp) + .background(surface_variant.copy(alpha = 0.85f), RoundedCornerShape(3.dp)) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) + } + } } } @@ -404,6 +541,11 @@ fun format_time(ms: Long): String { else "$m:${sec.toString().padStart(2, '0')}" } +private fun format_time_centis(ms: Long): String { + val s = ms / 1000; val m = s / 60; val sec = s % 60; val centis = (ms % 1000) / 10 + return "$m:${sec.toString().padStart(2, '0')}.${centis.toString().padStart(2, '0')}" +} + private fun format_speed(speed: Float): String { val rounded = (speed * 100).toLong() val whole = rounded / 100 diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt @@ -7,6 +7,7 @@ import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.model.AudioFileInfo import ubi2.sonodrill.model.LoopState import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.current_time_ms import ubi2.sonodrill.model.generate_uuid import ubi2.sonodrill.model.increment_play_count @@ -33,8 +34,20 @@ class PlayerViewModel( private val _waveform = MutableStateFlow(FloatArray(0)) val waveform: StateFlow<FloatArray> = _waveform.asStateFlow() + private val _skip_zones = MutableStateFlow<List<SkipZone>>(emptyList()) + val skip_zones: StateFlow<List<SkipZone>> = _skip_zones.asStateFlow() + + private val _play_delay_s = MutableStateFlow(3) + val play_delay_s: StateFlow<Int> = _play_delay_s.asStateFlow() + private val _play_delay_enabled = MutableStateFlow(false) + val play_delay_enabled: StateFlow<Boolean> = _play_delay_enabled.asStateFlow() + + fun set_play_delay(seconds: Int) { _play_delay_s.value = seconds } + fun toggle_play_delay() { _play_delay_enabled.value = !_play_delay_enabled.value } + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var save_job: Job? = null + private var skip_job: Job? = null fun load_file(file: AudioFileInfo) { _current_file.value = file @@ -48,9 +61,41 @@ class PlayerViewModel( _waveform.value = extract_waveform(file.uri, (dur / 20).toInt().coerceAtLeast(1)) } start_auto_save() + start_skip_monitoring() } - fun toggle_playback() = player.toggle() + private var delay_job: Job? = null + private val _countdown = MutableStateFlow(0) + val countdown: StateFlow<Int> = _countdown.asStateFlow() + + fun rename_file(new_title: String) { + val file = _current_file.value ?: return + val updated = file.copy(title = new_title, updated_at = current_time_ms()) + repo.upsert_file(updated) + _current_file.value = updated + } + + fun toggle_playback() { + if (is_playing.value) { + delay_job?.cancel() + _countdown.value = 0 + player.pause() + return + } + if (_play_delay_enabled.value && _play_delay_s.value > 0) { + delay_job?.cancel() + delay_job = scope.launch { + for (i in _play_delay_s.value downTo 1) { + _countdown.value = i + delay(1000) + } + _countdown.value = 0 + player.play() + } + } else { + player.play() + } + } fun seek(ms: Long) = player.seek(ms) fun set_speed(rate: Float) = player.set_speed(rate) fun skip(delta_ms: Long) = player.skip(delta_ms) @@ -73,6 +118,7 @@ class PlayerViewModel( repo.delete_mark(id) _marks.value = repo.get_marks(file.id) if (loop_state.value.mark_a?.id == id || loop_state.value.mark_b?.id == id) loop.clear() + cleanup_skip_zones() } fun set_loop_marks(a: MarkInfo, b: MarkInfo) = loop.set_loop(a, b) @@ -115,6 +161,7 @@ class PlayerViewModel( repo.delete_mark(prev.id) _marks.value = repo.get_marks(file.id) if (loop_state.value.mark_a?.id == prev.id || loop_state.value.mark_b?.id == prev.id) loop.clear() + cleanup_skip_zones() } fun delete_next_mark() { @@ -124,6 +171,7 @@ class PlayerViewModel( repo.delete_mark(next.id) _marks.value = repo.get_marks(file.id) if (loop_state.value.mark_a?.id == next.id || loop_state.value.mark_b?.id == next.id) loop.clear() + cleanup_skip_zones() } fun loop_around(position_ms: Long) { @@ -161,6 +209,47 @@ class PlayerViewModel( } } + private fun cleanup_skip_zones() { + val mark_positions = _marks.value.map { it.position_ms }.toSet() + _skip_zones.value = _skip_zones.value.filter { zone -> + mark_positions.contains(zone.start_ms) && mark_positions.contains(zone.end_ms) + } + } + + fun add_skip_zone() { + val pos = position.value + val sorted = _marks.value.sortedBy { it.position_ms } + val prev = sorted.lastOrNull { it.position_ms <= pos } + val next = sorted.firstOrNull { it.position_ms > pos } + if (prev != null && next != null) { + val zone = SkipZone(prev.position_ms, next.position_ms) + // Don't add duplicate + if (_skip_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { + _skip_zones.value = (_skip_zones.value + zone).sortedBy { it.start_ms } + } + } + } + + fun remove_skip_zone_at_position() { + val pos = position.value + _skip_zones.value = _skip_zones.value.filter { !(pos >= it.start_ms && pos < it.end_ms) } + } + + private fun start_skip_monitoring() { + skip_job?.cancel() + skip_job = scope.launch { + while (isActive) { + delay(80) + if (!is_playing.value) continue + val pos = position.value + val zone = _skip_zones.value.firstOrNull { pos >= it.start_ms && pos < it.end_ms } + if (zone != null) { + player.seek(zone.end_ms) + } + } + } + } + private fun start_auto_save() { save_job?.cancel() save_job = scope.launch { @@ -176,6 +265,7 @@ class PlayerViewModel( val file = _current_file.value if (file != null) repo.update_position(file.id, position.value, speed.value) save_job?.cancel() + skip_job?.cancel() loop.release() player.release() scope.cancel() diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt @@ -9,6 +9,8 @@ import platform.darwin.NSObject import ubi2.sonodrill.audio.WaveformProvider import ubi2.sonodrill.audio.create_audio_player import ubi2.sonodrill.audio.waveform_provider +import ubi2.sonodrill.tip.TipProvider +import ubi2.sonodrill.tip.tip_provider import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory import ubi2.sonodrill.data.create_database @@ -24,6 +26,10 @@ fun register_waveform_provider(provider: WaveformProvider) { waveform_provider = provider } +fun register_tip_provider(provider: TipProvider) { + tip_provider = provider +} + fun main_view_controller(): UIViewController { val db = create_database(DriverFactory()) val repo = AudioRepository(db) diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt @@ -12,6 +12,9 @@ actual fun open_url(url: String) { platform.UIKit.UIApplication.sharedApplication.openURL(nsurl, options = emptyMap<Any?, Any>(), completionHandler = null) } +actual fun has_tipped(): Boolean = ubi2.sonodrill.tip.tip_provider?.hasTipped() ?: false +actual fun purchase_tip() { ubi2.sonodrill.tip.tip_provider?.purchase() } + private const val PLAY_COUNT_KEY = "sonodrill_play_count" actual fun get_play_count(): Int = platform.Foundation.NSUserDefaults.standardUserDefaults.integerForKey(PLAY_COUNT_KEY).toInt() diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt @@ -0,0 +1,8 @@ +package ubi2.sonodrill.tip + +interface TipProvider { + fun hasTipped(): Boolean + fun purchase() +} + +var tip_provider: TipProvider? = null diff --git a/iosApp/ContentView.swift b/iosApp/ContentView.swift @@ -11,6 +11,7 @@ struct ContentView: View { struct ComposeViewControllerRepresentable: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { Main_iosKt.register_waveform_provider(provider: SwiftWaveformProvider()) + Main_iosKt.register_tip_provider(provider: SwiftTipProvider()) return Main_iosKt.main_view_controller() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} diff --git a/iosApp/ExportOptions.plist b/iosApp/ExportOptions.plist @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>method</key> + <string>app-store-connect</string> + <key>signingStyle</key> + <string>automatic</string> + <key>teamID</key> + <string>8CH9CCT4MH</string> + <key>uploadSymbols</key> + <true/> +</dict> +</plist> diff --git a/iosApp/Info.plist b/iosApp/Info.plist @@ -34,6 +34,7 @@ <key>UISupportedInterfaceOrientations</key> <array> <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeRight</string> </array> diff --git a/iosApp/TipManager.swift b/iosApp/TipManager.swift @@ -0,0 +1,65 @@ +import StoreKit +import ComposeApp + +class SwiftTipProvider: TipProvider { + static let productId = "ubi2.sonodrill.tip" + private var purchased = false + private var product: Product? + + init() { + self.product = nil + Task { + await loadProduct() + await checkPurchased() + } + } + + private func loadProduct() async { + do { + let products = try await Product.products(for: [SwiftTipProvider.productId]) + self.product = products.first + } catch { + print("Sonodrill: failed to load tip product: \(error)") + } + } + + private func checkPurchased() async { + for await result in Transaction.currentEntitlements { + if case .verified(let tx) = result, tx.productID == SwiftTipProvider.productId { + purchased = true + return + } + } + } + + func hasTipped() -> Bool { + return purchased + } + + func purchase() { + Task { + guard let product = self.product else { + print("Sonodrill: tip product not loaded") + return + } + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + if case .verified(let tx) = verification { + await tx.finish() + purchased = true + } + case .userCancelled: + break + case .pending: + break + @unknown default: + break + } + } catch { + print("Sonodrill: purchase failed: \(error)") + } + } + } +} diff --git a/iosApp/fastlane/Fastfile b/iosApp/fastlane/Fastfile @@ -1,41 +1,64 @@ default_platform(:ios) platform :ios do - desc "Build Kotlin framework and archive for App Store" + desc "Build and upload to TestFlight" lane :release do - # Build Kotlin framework first - Dir.chdir("..") do - sh("cd .. && ./gradlew :composeApp:assembleReleaseAppleFrameworkForXcode " \ - "-PXCODE_CONFIGURATION=Release " \ - "-PXCODE_SDK=iphoneos " \ - "-PXCODE_ARCHS=arm64") - end - - # Regenerate Xcode project sh("cd .. && xcodegen generate") - api_key = app_store_connect_api_key( - key_id: ENV["UBI2_ASC_KEY_ID"], - issuer_id: ENV["UBI2_ASC_ISSUER_ID"], - key_filepath: ENV["UBI2_ASC_KEY_PATH"], - ) - + build_number = Time.now.strftime("%Y%m%d%H%M") increment_build_number( xcodeproj: "iosApp.xcodeproj", - build_number: Time.now.strftime("%Y%m%d%H%M"), + build_number: build_number, ) - build_app( - project: "iosApp.xcodeproj", - scheme: "iosApp", - export_method: "app-store", - destination: "generic/platform=iOS", - xcargs: "-allowProvisioningUpdates", + archive_path = File.expand_path("~/Library/Developer/Xcode/Archives/Sonodrill-#{build_number}.xcarchive") + output_dir = File.expand_path("../build_output") + FileUtils.rm_rf(output_dir) + + export_plist = File.expand_path("../ExportOptions.plist") + File.write(export_plist, <<~PLIST) + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>method</key> + <string>app-store-connect</string> + <key>signingStyle</key> + <string>automatic</string> + <key>teamID</key> + <string>8CH9CCT4MH</string> + <key>uploadSymbols</key> + <true/> + </dict> + </plist> + PLIST + + # Archive + Export + Upload in one pipeline using Xcode's built-in ASC auth + sh( + "xcodebuild", + "-scheme", "iosApp", + "-project", "../iosApp.xcodeproj", + "-destination", "generic/platform=iOS", + "-archivePath", archive_path, + "-allowProvisioningUpdates", + "-authenticationKeyPath", ENV["UBI2_ASC_KEY_PATH"], + "-authenticationKeyID", ENV["UBI2_ASC_KEY_ID"], + "-authenticationKeyIssuerID", ENV["UBI2_ASC_ISSUER_ID"], + "archive", ) - upload_to_testflight( - api_key: api_key, - skip_waiting_for_build_processing: true, + sh( + "xcodebuild", + "-exportArchive", + "-archivePath", archive_path, + "-exportOptionsPlist", export_plist, + "-exportPath", output_dir, + "-allowProvisioningUpdates", + "-authenticationKeyPath", ENV["UBI2_ASC_KEY_PATH"], + "-authenticationKeyID", ENV["UBI2_ASC_KEY_ID"], + "-authenticationKeyIssuerID", ENV["UBI2_ASC_ISSUER_ID"], ) + + UI.success("Upload complete! Check TestFlight for build #{build_number}") end end diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -11,15 +11,17 @@ 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */; }; 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */; }; 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; - 5ADAC7381145BCE241DC42A4 /* iosApp.xcodeproj in Resources */ = {isa = PBXBuildFile; fileRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; }; + 957C4BEC91B3575B6F6D7D5C /* TipManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201A2D6E0D8E19221257318F /* TipManager.swift */; }; + AD99F711E95E895398A8473F /* ExportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249877C37C47F33E2749A7B /* ExportOptions.plist */; }; F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 245434685F34A9A15B1AC406 /* libsqlite3.tbd */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 201A2D6E0D8E19221257318F /* TipManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipManager.swift; sourceTree = "<group>"; }; 245434685F34A9A15B1AC406 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformExtractor.swift; sourceTree = "<group>"; }; - 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = iosApp.xcodeproj; sourceTree = "<group>"; }; - 86038AE1032E284E79B32AD6 /* Sonodrill.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sonodrill.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9249877C37C47F33E2749A7B /* ExportOptions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = ExportOptions.plist; sourceTree = "<group>"; }; CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonodrillApp.swift; sourceTree = "<group>"; }; F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; @@ -42,8 +44,9 @@ children = ( F543015C899423D4B01CEB2E /* Assets.xcassets */, CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, - 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */, + 9249877C37C47F33E2749A7B /* ExportOptions.plist */, E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */, + 201A2D6E0D8E19221257318F /* TipManager.swift */, 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */, ); name = iosApp; @@ -67,15 +70,10 @@ ); sourceTree = "<group>"; }; - 5FF97F652F7D4E980050BA4C /* Products */ = { - isa = PBXGroup; - name = Products; - sourceTree = "<group>"; - }; 94C98B8B3A2BFC1966447269 /* Products */ = { isa = PBXGroup; children = ( - 86038AE1032E284E79B32AD6 /* Sonodrill.app */, + 86038AE1032E284E79B32AD6 /* iosApp.app */, ); name = Products; sourceTree = "<group>"; @@ -100,7 +98,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 86038AE1032E284E79B32AD6 /* Sonodrill.app */; + productReference = 86038AE1032E284E79B32AD6 /* iosApp.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -111,6 +109,11 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; + TargetAttributes = { + 78B53CC79860CBFC2F5543C6 = { + DevelopmentTeam = 8CH9CCT4MH; + }; + }; }; buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */; compatibilityVersion = "Xcode 14.0"; @@ -122,13 +125,8 @@ ); mainGroup = 38B69C3F79CD97858CF7B609; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; projectDirPath = ""; - projectReferences = ( - { - ProductGroup = 5FF97F652F7D4E980050BA4C /* Products */; - ProjectRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; - }, - ); projectRoot = ""; targets = ( 78B53CC79860CBFC2F5543C6 /* iosApp */, @@ -142,7 +140,7 @@ buildActionMask = 2147483647; files = ( 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, - 5ADAC7381145BCE241DC42A4 /* iosApp.xcodeproj in Resources */, + AD99F711E95E895398A8473F /* ExportOptions.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -177,6 +175,7 @@ files = ( 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */, + 957C4BEC91B3575B6F6D7D5C /* TipManager.swift in Sources */, 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -219,7 +218,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 8CH9CCT4MH; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -276,7 +275,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 8CH9CCT4MH; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -310,14 +309,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 8CH9CCT4MH; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - OTHER_LINKER_FLAGS = "$(inherited) -framework ComposeApp -lsqlite3"; + OTHER_LINKER_FLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + "-lsqlite3", + ); PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; PRODUCT_NAME = Sonodrill; SDKROOT = iphoneos; @@ -330,14 +335,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 8CH9CCT4MH; - FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - OTHER_LINKER_FLAGS = "$(inherited) -framework ComposeApp -lsqlite3"; + OTHER_LINKER_FLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + "-lsqlite3", + ); PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; PRODUCT_NAME = Sonodrill; SDKROOT = iphoneos; diff --git a/iosApp/project.yml b/iosApp/project.yml @@ -5,7 +5,7 @@ options: iOS: "16.0" settings: base: - DEVELOPMENT_TEAM: "" + DEVELOPMENT_TEAM: "8CH9CCT4MH" targets: iosApp: type: application @@ -16,6 +16,8 @@ targets: - "Configuration" - "project.yml" - "Info.plist" + - "fastlane" + - "*.xcodeproj" info: path: Info.plist properties: @@ -27,6 +29,7 @@ targets: UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight settings: