diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index f20fc4a..10db264 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -15,7 +15,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Bump patch version and version_code run: | diff --git a/.gitignore b/.gitignore index 944856f..1d6655c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,20 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db -/nativephp + /database/database.sqlite /app-release-signed.apk* /my-release-key* + +# Credential files (keystores, private keys, etc.) +/credentials/ + +/* AI */ +.agents +.mcp.json +CLAUDE.md +AGENTS.md +GEMINI.md +.claude +.junie +.gemini diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php index 76559d8..1372630 100644 --- a/app/Livewire/ProgramEditor.php +++ b/app/Livewire/ProgramEditor.php @@ -217,7 +217,11 @@ public function formattedDuration(): string public function totalDuration(): int { - return array_reduce( + if (empty($this->phases)) { + return 0; + } + + $total = array_reduce( $this->phases, static function (int $carry, array $p): int { $repTime = $p['duration'] * $p['repetitions']; @@ -226,6 +230,9 @@ static function (int $carry, array $p): int { }, 0, ); + + // The last phase's cooldown is never executed (timer goes straight to Complete). + return $total - (int) ($this->phases[array_key_last($this->phases)]['cooldown'] ?? 0); } /** diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 305b63c..8d04d4e 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -17,9 +17,13 @@ class Settings extends Component { public BeepLeadIn $defaultBeepLeadIn = BeepLeadIn::Three; + public string $defaultEndSound = 'triple'; + public string $soundMode = 'beep'; + public float $volume = 0.8; + public bool $keepScreenOn = true; public bool $saved = false; @@ -29,10 +33,10 @@ public function mount(): void $settings = Setting::current(); $this->defaultBeepLeadIn = $settings->default_beep_lead_in; - $this->defaultEndSound = $settings->default_end_sound; - $this->soundMode = $settings->sound_mode; - $this->volume = $settings->volume; - $this->keepScreenOn = $settings->keep_screen_on; + $this->defaultEndSound = $settings->default_end_sound; + $this->soundMode = $settings->sound_mode; + $this->volume = $settings->volume; + $this->keepScreenOn = $settings->keep_screen_on; } public function render(): View @@ -44,24 +48,36 @@ public function save(): void { $this->validate([ 'defaultBeepLeadIn' => ['required', new Enum(BeepLeadIn::class)], - 'defaultEndSound' => 'required|in:triple,chime', - 'soundMode' => 'required|in:beep,voice', - 'volume' => 'required|numeric|min:0|max:1', - 'keepScreenOn' => 'boolean', + 'defaultEndSound' => 'required|in:triple,chime', + 'soundMode' => 'required|in:beep,voice', + 'volume' => 'required|numeric|min:0|max:1', + 'keepScreenOn' => 'boolean', ]); $settings = Setting::current(); $settings->default_beep_lead_in = $this->defaultBeepLeadIn; - $settings->default_end_sound = $this->defaultEndSound; - $settings->sound_mode = $this->soundMode; - $settings->volume = round((float) $this->volume, 2); - $settings->keep_screen_on = $this->keepScreenOn; + $settings->default_end_sound = $this->defaultEndSound; + $settings->sound_mode = $this->soundMode; + $settings->volume = round((float)$this->volume, 2); + $settings->keep_screen_on = $this->keepScreenOn; $settings->save(); - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: null); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: null); $this->saved = true; } + + public function updateAndTest(string $soundMode): void + { + $this->soundMode = $soundMode; + if (app()->isLocal()) { + if ($soundMode === 'voice') { + $this->dispatch('play-TTS-Sound', text: '3, 2, 1, - GO'); + } else { + $this->dispatch('playBeepSound', sound: 'triple'); + } + } + } } diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php index abb7958..b6c927d 100644 --- a/app/Livewire/TimerScreen.php +++ b/app/Livewire/TimerScreen.php @@ -23,27 +23,39 @@ class TimerScreen extends Component { // ── Identifiers ─────────────────────────────────────────────────────── public ?string $programId = null; + public string $programName = ''; // ── Cursor snapshot (serializable scalars for Livewire) ────────────── public StateMachine $state = StateMachine::idle; + public int $remaining = 0; + public int $totalRemaining = 0; + public int $phaseIndex = 0; + public int $repIndex = 0; // ── Phase display ───────────────────────────────────────────────────── public string $phaseLabel = ''; + public string $phaseColor = '#3b82f6'; + public int $phaseReps = 1; + /** @var array[] Serialised Phase rows for the phase strip */ public array $phases = []; // ── Settings (for the JS audio layer) ──────────────────────────────── public string $soundMode = 'beep'; + public float $volume = 0.8; + public string $endSound = 'triple'; + public bool $keepScreenOn = true; + // ── Ring countdown ──────────────────────────────────────────────────── public int $programTotalDuration = 0; @@ -54,155 +66,61 @@ class TimerScreen extends Component /** @var array[] serialised HistoryEntry rows + 'program_exists' bool */ public array $history = []; - public function mount(?string $id = null): void - { - $settings = Setting::current(); - $this->soundMode = $settings->sound_mode; - $this->volume = $settings->volume; - - if ($id) { - $this->loadProgram($id); - } else { - $this->loadHistory(); - } - } - - // ── Timer controls ──────────────────────────────────────────────────── - - public function loadProgram(string $id): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($id); - - $this->programId = $id; - $this->programName = $program->name; - $this->endSound = $program->end_sound; - $this->programTotalDuration = $program->totalDuration(); - $this->rehydrateRunner($runner); - - $this->syncCursor($runner->cursor(), $program); - - $this->dispatch('topbar-title', title: $program->name); - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program); - } - public function discard(): void { app(TimerRunner::class)->discard(); - $this->state = StateMachine::idle; - $this->remaining = 0; + $this->state = StateMachine::idle; + $this->remaining = 0; $this->totalRemaining = 0; $this->dispatch('topbar-title', title: config('app.name')); } - public function pause(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - $runner->pause(); - $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); - } - - public function resume(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - $runner->resume(); - $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); - } - - public function restart(): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($this->programId); - $this->programTotalDuration = $program->totalDuration(); - $runner->load($program); - $this->syncCursor($runner->cursor(), $program); - $this->dispatch('topbar-title', title: config('app.name')); - } - - public function start(): void - { - $runner = app(TimerRunner::class); - $program = Program::with('phases')->findOrFail($this->programId); - - $this->programTotalDuration = $program->totalDuration(); - $runner->load($program); - $runner->start(); - $this->syncCursor($runner->cursor(), $program); - - $this->dispatch('topbar-title', title: $this->programName); - } - - /** Called every second from JS setInterval via wire:poll equivalent. */ - public function tick(): void - { - $runner = app(TimerRunner::class); - $this->rehydrateRunner($runner); - - if (!$runner->cursor()->isActive()) { - return; - } - - $runner->tick(); - $cursor = $runner->cursor(); - $program = Program::with('phases')->findOrFail($this->programId); - - $this->syncCursor($cursor, $program); - - if ($cursor->isCompleted()) { - Log::info('Completed!'); - $this->dispatch('playEndSound', sound: $this->endSound); - $this->dispatch('topbar-title', title: config('app.name')); - } - } - - public function requestSettings(): void - { - $program = $this->programId ? Program::with('phases')->find($this->programId) : null; - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program); - } - - public function render(): View - { - return view('livewire.timer-screen'); - } - - // ── Display helpers ─────────────────────────────────────────────────── + // ── Timer controls ──────────────────────────────────────────────────── public function formattedRemaining(): string { $s = $this->remaining; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); } public function formattedTotal(): string { $s = $this->totalRemaining; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); } - public function repLabel(): string + public function mount(?string $id = null): void { - if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) { - return ''; + $settings = Setting::current(); + $this->soundMode = $settings->sound_mode; + $this->volume = $settings->volume; + $this->keepScreenOn = $settings->keep_screen_on; + + if ($id) { + $this->loadProgram($id); + } else { + $this->loadHistory(); } - return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps); } - public function segmentLabel(): string + public function loadProgram(string $id): void { - return match ($this->state) { - StateMachine::prepare => 'Get Ready', - StateMachine::pause => 'Pause', - StateMachine::cooldown => 'Cooldown', - StateMachine::paused => 'Paused', - StateMachine::completed => 'Complete!', - default => $this->phaseLabel, - }; - } + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($id); - // ── Internals ───────────────────────────────────────────────────────── + $this->programId = $id; + $this->programName = $program->name; + $this->endSound = $program->end_sound; + $this->programTotalDuration = $program->totalDuration(); + $this->rehydrateRunner($runner); + + $this->syncCursor($runner->cursor(), $program); + + $this->dispatch('topbar-title', title: $program->name); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); + } private function rehydrateRunner(TimerRunner $runner): void { @@ -214,10 +132,10 @@ private function rehydrateRunner(TimerRunner $runner): void $runner->load($program); $cursor = new TimerCursor( - phaseIndex: $this->phaseIndex, - repIndex: $this->repIndex, - state: $this->state, - remaining: $this->remaining, + phaseIndex: $this->phaseIndex, + repIndex: $this->repIndex, + state: $this->state, + remaining: $this->remaining, totalRemaining: $this->totalRemaining, ); @@ -233,31 +151,31 @@ private function rehydrateRunner(TimerRunner $runner): void private function handleBeep(string $reason): void { $this->countdownLabel = match ($reason) { - 'prepare', 'countdown' => (string) $this->remaining, - 'rep_end' => 'Done', - 'pause_end' => 'Go', - 'cooldown_end' => 'Next', - default => '', + 'prepare', 'countdown' => (string)($this->remaining - 1), + 'rep_end' => 'Done', + 'pause_end' => 'Go', + 'cooldown_end' => 'Next', + default => '', }; $this->dispatch('playBeep', reason: $reason); } private function syncCursor(TimerCursor $cursor, Program $program): void { - $this->state = $cursor->state; - $this->remaining = $cursor->remaining; + $this->state = $cursor->state; + $this->remaining = $cursor->remaining; $this->totalRemaining = $cursor->totalRemaining; - $this->phaseIndex = $cursor->phaseIndex; - $this->repIndex = $cursor->repIndex; + $this->phaseIndex = $cursor->phaseIndex; + $this->repIndex = $cursor->repIndex; $this->phases = $program->phases ->map(fn(Phase $p) => [ - 'label' => $p->label, - 'duration' => $p->duration, + 'label' => $p->label, + 'duration' => $p->duration, 'repetitions' => $p->repetitions, - 'pause' => $p->pause, - 'cooldown' => $p->cooldown, - 'color' => $p->color, + 'pause' => $p->pause, + 'cooldown' => $p->cooldown, + 'color' => $p->color, ]) ->all(); @@ -265,7 +183,7 @@ private function syncCursor(TimerCursor $cursor, Program $program): void $phase = $program->phases[$cursor->phaseIndex]; $this->phaseLabel = $phase->label; $this->phaseColor = $phase->color; - $this->phaseReps = $phase->repetitions; + $this->phaseReps = $phase->repetitions; } } @@ -279,12 +197,114 @@ private function loadHistory(): void $this->history = $entries ->map(fn(HistoryEntry $e) => [ - 'program_id' => $e->program_id, - 'program_name' => $e->program_name, - 'completed_at' => $e->completed_at->toISOString(), + 'program_id' => $e->program_id, + 'program_name' => $e->program_name, + 'completed_at' => $e->completed_at->toISOString(), 'total_duration' => $e->total_duration, 'program_exists' => $e->program_id !== null && $existingIds->has($e->program_id), ]) ->all(); } + + public function pause(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->pause(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + // ── Display helpers ─────────────────────────────────────────────────── + + public function render(): View + { + return view('livewire.timer-screen'); + } + + public function repLabel(): string + { + if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) { + return ''; + } + + return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps); + } + + public function requestSettings(): void + { + $program = $this->programId ? Program::with('phases')->find($this->programId) : null; + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, keepScreenOn: $this->keepScreenOn, program: $program); + } + + public function restart(): void + { + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($this->programId); + $this->programTotalDuration = $program->totalDuration(); + $runner->load($program); + $this->syncCursor($runner->cursor(), $program); + $this->dispatch('topbar-title', title: config('app.name')); + } + + // ── Internals ───────────────────────────────────────────────────────── + + public function resume(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->resume(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + public function segmentLabel(): string + { + return match ($this->state) { + StateMachine::prepare => 'Get Ready', + StateMachine::pause => 'Pause', + StateMachine::cooldown => 'Cooldown', + StateMachine::paused => 'Paused', + StateMachine::completed => 'Complete!', + default => $this->phaseLabel, + }; + } + + public function start(): void + { + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($this->programId); + + if ($program->phases->isEmpty()) { + return; + } + + $this->programTotalDuration = $program->totalDuration(); + $runner->load($program); + $runner->start(); + $this->syncCursor($runner->cursor(), $program); + + $this->dispatch('topbar-title', title: $this->programName); + } + + /** Called every second from JS setInterval via wire:poll equivalent. */ + public function tick(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + + if (!$runner->cursor()->isActive()) { + return; + } + + $runner->tick(); + $cursor = $runner->cursor(); + $program = Program::with('phases')->findOrFail($this->programId); + + $this->syncCursor($cursor, $program); + + if ($cursor->isCompleted()) { + Log::info('Completed!'); + $this->dispatch('playEndSound', sound: $this->endSound); + $this->dispatch('topbar-title', title: config('app.name')); + } + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index f62fee6..b657969 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -30,12 +30,6 @@ class Setting extends Model /** Returns the single settings row, creating it with defaults on first run. */ public static function current(): self { - return self::first() ?? self::create([ - 'default_beep_lead_in' => BeepLeadIn::Three->value, - 'default_end_sound' => 'triple', - 'sound_mode' => 'beep', - 'volume' => 0.8, - 'keep_screen_on' => true, - ]); + return self::first(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 608f2fb..0d97006 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,6 @@ public function register(): void */ public function boot(): void { - // + } } diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php new file mode 100644 index 0000000..f185763 --- /dev/null +++ b/app/Providers/NativeServiceProvider.php @@ -0,0 +1,42 @@ +> + */ + public function plugins(): array + { + return [ + AudioTTSServiceProvider::class, + + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..fd45a88 --- /dev/null +++ b/boost.json @@ -0,0 +1,22 @@ +{ + "agents": [ + "junie", + "claude_code", + "gemini" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "packages": [ + "nbucic/audio-tts", + "nativephp/mobile" + ], + "sail": false, + "skills": [ + "laravel-best-practices", + "livewire-development", + "pest-testing", + "tailwindcss-development", + "nativephp-mobile" + ] +} diff --git a/composer.json b/composer.json index e84e93a..4c1a44f 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,11 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.5", "laravel/pint": "^1.27", "mockery/mockery": "^1.6", + "nbucic/audio-tts": "*@dev", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.0", "pestphp/pest-plugin-laravel": "^4.0", @@ -50,10 +52,18 @@ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], + "mobile": [ + "npm run build -- --mode=android", + "@php artisan native:run android" + ], "test": [ "@php artisan config:clear --ansi", "@php artisan test" ], + "build": [ + "@php artisan config:clear --ansi", + "@php artisan native:package android" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" @@ -88,5 +98,11 @@ } }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "type": "path", + "url": "/home/nikola/projects/private/interval-timer-nativephp/packages/nbucic/audio-tts" + } + ] } diff --git a/composer.lock b/composer.lock index def5b74..caab011 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ed46c3d9d7188821f6ff9a70722847be", + "content-hash": "28d31365cf997193a36f285d07a4b900", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -135,6 +190,56 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -508,6 +613,78 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.1.3", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.4" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.1.3" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-02-05T07:01:58+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -2251,19 +2428,20 @@ }, { "name": "nativephp/mobile", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "866a6dd9896cfdee3f33bfb3a95d568711a6ff02" + "reference": "adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/866a6dd9896cfdee3f33bfb3a95d568711a6ff02", - "reference": "866a6dd9896cfdee3f33bfb3a95d568711a6ff02", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6", + "reference": "adbf8f78fd52c7e74c1dfc82f36b44b61357d3b6", "shasum": "" }, "require": { + "endroid/qr-code": "^6.1.3", "ext-dom": "*", "ext-simplexml": "*", "guzzlehttp/guzzle": "^7.9", @@ -2276,7 +2454,6 @@ "workerman/workerman": "^4.1" }, "require-dev": { - "endroid/qr-code": "^5.0", "larastan/larastan": "^2.0", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.9", @@ -2328,9 +2505,9 @@ ], "support": { "issues": "https://github.com/NativePHP/mobile-air/issues", - "source": "https://github.com/NativePHP/mobile-air/tree/3.2.2" + "source": "https://github.com/NativePHP/mobile-air/tree/3.2.3" }, - "time": "2026-04-04T20:00:18+00:00" + "time": "2026-04-10T19:30:15+00:00" }, { "name": "nesbot/carbon", @@ -7038,6 +7215,145 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laravel/boost", + "version": "v2.4.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-04-10T15:59:10+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2", + "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-15T08:30:42+00:00" + }, { "name": "laravel/pail", "version": "v1.2.6", @@ -7186,6 +7502,67 @@ }, "time": "2026-03-12T15:51:39+00:00" }, + { + "name": "laravel/roster", + "version": "v0.5.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-03-05T07:58:43+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -7329,6 +7706,56 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "nbucic/audio-tts", + "version": "dev-fix/timer-loading-and-keepscreenon", + "dist": { + "type": "path", + "url": "/home/nikola/projects/private/interval-timer-nativephp/packages/nbucic/audio-tts", + "reference": "0d098f04f0cdd89f14f1b895f5a7e8a5c20e7556" + }, + "require": { + "nativephp/mobile": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "pestphp/pest": "^3.0" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Nbucic\\AudioTts\\AudioTTSServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Nbucic\\AudioTts\\": "src/" + } + }, + "scripts": { + "test": [ + "pest" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Your Name", + "email": "you@example.com" + } + ], + "description": "An Audio TTS plugin for Android", + "transport-options": { + "relative": false + } + }, { "name": "nunomaduro/collision", "version": "v8.9.2", @@ -9613,6 +10040,81 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/yaml", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.7", @@ -9787,7 +10289,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "nbucic/audio-tts": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/config/nativephp.php b/config/nativephp.php index d12250d..8982051 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -275,6 +275,7 @@ 'resources', 'routes', 'config', + 'database', 'public', ], diff --git a/database/migrations/2026_04_16_081129_initial_program_seed.php b/database/migrations/2026_04_16_081129_initial_program_seed.php new file mode 100644 index 0000000..25b9fde --- /dev/null +++ b/database/migrations/2026_04_16_081129_initial_program_seed.php @@ -0,0 +1,67 @@ +truncate(); + + DB::table('settings')->insert([ + 'default_beep_lead_in' => BeepLeadIn::Three->value, + 'default_end_sound' => 'triple', + 'sound_mode' => 'beep', + 'volume' => 0.8, + 'keep_screen_on' => true, + ]); + + $programId = Str::uuid7()->toString(); + Log::info(sprintf('Program ID: %s', $programId)); + + DB::table('programs')->insert([ + 'id' => $programId, + 'name' => 'HIIT', + 'beep_lead_in' => BeepLeadIn::Three->value, + 'end_sound' => 'chime', + ]); + + foreach ([ + ['label' => 'Warmup', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 5, 'color' => '#3b82f6'], + ['label' => 'Sprint', 'duration' => 8, 'repetitions' => 3, 'pause' => 4, 'cooldown' => 10, 'color' => '#ef4444'], + ['label' => 'Stretch', 'duration' => 8, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#22c55e'], + ] as $index => $phase) { + DB::table('phases')->insert( + array_merge( + $phase, + [ + 'program_id' => $programId, + 'sort_order' => $index, + ], + ), + ); + } + + DB::table('programs')->insert([ + 'id' => Str::uuid7()->toString(), + 'name' => 'Demo with no phases', + 'beep_lead_in' => 3, + 'end_sound' => 'triple', + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/package-lock.json b/package-lock.json index 6dbefb1..a24afc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": ">=1.11.0 <=1.14.0", + "axios": "1.15.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.0.0", "tailwindcss": "^4.0.0", @@ -101,9 +101,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -120,9 +120,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -130,9 +130,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -164,9 +164,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -181,9 +181,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -198,9 +198,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -215,13 +215,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -232,13 +235,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -249,13 +255,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -266,13 +275,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -283,13 +295,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -300,13 +315,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -317,9 +335,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -334,9 +352,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -344,16 +362,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -368,9 +388,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -385,9 +405,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -524,6 +544,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -541,6 +564,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -558,6 +584,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -575,6 +604,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -732,9 +764,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -994,9 +1026,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1361,6 +1393,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1382,6 +1417,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1403,6 +1441,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1424,6 +1465,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1562,9 +1606,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1611,14 +1655,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1627,21 +1671,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/rxjs": { @@ -1743,14 +1787,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1777,16 +1821,16 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", - "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { diff --git a/package.json b/package.json index 28b2977..78710ad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": ">=1.11.0 <=1.14.0", + "axios": "1.15.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.0.0", "tailwindcss": "^4.0.0", diff --git a/packages/nbucic/audio-tts/.gitignore b/packages/nbucic/audio-tts/.gitignore new file mode 100644 index 0000000..4deac1a --- /dev/null +++ b/packages/nbucic/audio-tts/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/node_modules/ +.DS_Store +*.log \ No newline at end of file diff --git a/packages/nbucic/audio-tts/README.md b/packages/nbucic/audio-tts/README.md new file mode 100644 index 0000000..20504fd --- /dev/null +++ b/packages/nbucic/audio-tts/README.md @@ -0,0 +1,37 @@ +# AudioTTS Plugin for NativePHP Mobile + +An Audio TTS plugin for Android + +## Installation + +```bash +composer require nbucic/audio-tts +``` + +## Usage + +```php +use Nbucic\AudioTts\Facades\AudioTTS; + +// Execute functionality +$result = AudioTTS::execute(['option1' => 'value']); + +// Get status +$status = AudioTTS::getStatus(); +``` + +## Listening for Events + +```php +use Livewire\Attributes\On; + +#[On('native:Nbucic\AudioTts\Events\AudioTTSCompleted')] +public function handleAudioTTSCompleted($result, $id = null) +{ + // Handle the event +} +``` + +## License + +MIT \ No newline at end of file diff --git a/packages/nbucic/audio-tts/composer.json b/packages/nbucic/audio-tts/composer.json new file mode 100644 index 0000000..5c2030f --- /dev/null +++ b/packages/nbucic/audio-tts/composer.json @@ -0,0 +1,42 @@ +{ + "name": "nbucic/audio-tts", + "description": "An Audio TTS plugin for Android", + "type": "nativephp-plugin", + "license": "MIT", + "authors": [ + { + "name": "Your Name", + "email": "you@example.com" + } + ], + "require": { + "php": "^8.2", + "nativephp/mobile": "^3.0" + }, + "autoload": { + "psr-4": { + "Nbucic\\AudioTts\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Nbucic\\AudioTts\\AudioTTSServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "require-dev": { + "pestphp/pest": "^3.0" + }, + "scripts": { + "test": "pest" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/nativephp.json b/packages/nbucic/audio-tts/nativephp.json new file mode 100644 index 0000000..e756153 --- /dev/null +++ b/packages/nbucic/audio-tts/nativephp.json @@ -0,0 +1,73 @@ +{ + "name": "nbucic/audio-tts", + "version": "1.0.0", + "description": "An Audio TTS plugin for Android", + "namespace": "AudioTTS", + "keywords": [], + "category": "utilities", + "license": "MIT", + "pricing": { + "type": "free" + }, + "author": { + "name": "Nikola Bucić", + "email": "nikola.bucic@gmail.com", + "url": "https://github.com/nbucic" + }, + "homepage": "", + "repository": "", + "funding": [], + "platforms": [ + "android", + "ios" + ], + "icon": "resources/icon.png", + "screenshots": [], + "bridge_functions": [ + { + "name": "AudioTTS.Speak", + "android": "com.nbucic.plugins.audio_tts.AudioTTSFunctions.Speak", + "ios": "AudioTTSFunctions.Speak", + "description": "Speak text via Android TTS" + }, + { + "name": "AudioTTS.GetStatus", + "android": "com.nbucic.plugins.audio_tts.AudioTTSFunctions.GetStatus", + "ios": "AudioTTSFunctions.GetStatus", + "description": "Return TTS engine readiness state" + } + ], + "android": { + "min_version": 21, + "permissions": [], + "repositories": [], + "dependencies": { + "implementation": [] + }, + "activities": [], + "services": [], + "receivers": [], + "providers": [] + }, + "ios": { + "min_version": "15.0", + "permissions": [], + "repositories": [], + "dependencies": { + "swift_packages": [], + "pods": [] + } + }, + "assets": { + "android": [], + "ios": [] + }, + "secrets": [], + "events": [ + "Nbucic\\AudioTts\\Events\\AudioTTSCompleted" + ], + "service_provider": "Nbucic\\AudioTts\\AudioTTSServiceProvider", + "hooks": { + "copy_assets": "nativephp:audio-t-t-s:copy-assets" + } +} diff --git a/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt b/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt new file mode 100644 index 0000000..6efbb22 --- /dev/null +++ b/packages/nbucic/audio-tts/resources/android/AudioTTSFunctions.kt @@ -0,0 +1,157 @@ +package com.nbucic.plugins.audio_tts + +import android.content.Context +import android.media.AudioManager +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.fragment.app.FragmentActivity +import com.nativephp.mobile.bridge.BridgeFunction +import com.nativephp.mobile.bridge.BridgeResponse + +private const val TAG = "AudioTTSEngine" + +/** + * Singleton TTS engine — initialized once at app startup, shared across all bridge functions. + * Mirrors the lifecycle pattern from react-native-tts (TextToSpeechModule). + */ +private object TTSEngine { + + private var tts: TextToSpeech? = null + + @Volatile + private var ready = false + + @Volatile + private var initialized = false + + @Synchronized + fun initialize(context: Context) { + if (initialized) return + initialized = true + + tts = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { + val langResult = tts?.setLanguage(java.util.Locale.US) + if (langResult == TextToSpeech.LANG_MISSING_DATA || + langResult == TextToSpeech.LANG_NOT_SUPPORTED + ) { + Log.e(TAG, "Language not supported (result=$langResult)") + } else { + tts?.setPitch(0.9f) + tts?.setSpeechRate(0.85f) + ready = true + Log.d(TAG, "TTS engine ready") + attachProgressListener() + } + } else { + Log.e(TAG, "TTS init failed with status=$status") + } + } + } + + val isReady: Boolean get() = ready + + /** + * Speak text at the given volume (0.0–1.0). + * + * Uses QUEUE_ADD so phrases queue rather than cutting each other off. + * Uses Bundle params (API 21+) to set stream and volume — same approach as react-native-tts. + */ + fun speak(text: String, volume: Float = 1.0f): Boolean { + if (!ready) { + Log.w(TAG, "speak() called before engine ready — dropping: \"$text\"") + return false + } + + val utteranceId = text.hashCode().toString() + val params = Bundle().apply { + putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC) + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.coerceIn(0f, 1f)) + } + + val result = tts?.speak(text, TextToSpeech.QUEUE_ADD, params, utteranceId) + return if (result == TextToSpeech.SUCCESS) { + Log.d(TAG, "speak() queued: \"$text\"") + true + } else { + Log.e(TAG, "speak() failed (result=$result) for: \"$text\"") + false + } + } + + private fun attachProgressListener() { + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + Log.d(TAG, "onStart utteranceId=$utteranceId") + } + + override fun onDone(utteranceId: String) { + Log.d(TAG, "onDone utteranceId=$utteranceId") + } + + override fun onError(utteranceId: String) { + Log.e(TAG, "onError utteranceId=$utteranceId") + } + }) + } + + fun shutdown() { + tts?.stop() + tts?.shutdown() + tts = null + ready = false + initialized = false + Log.d(TAG, "TTS engine shut down") + } +} + +/** + * NativePHP bridge functions for the AudioTTS plugin. + * + * Each class is instantiated once at app startup by PluginBridgeFunctionRegistration, + * so TTSEngine.initialize() runs exactly once across all bridge function constructors. + */ +object AudioTTSFunctions { + + /** + * Speak text via Android TTS. + * + * Parameters: + * text (String, required) — the phrase to speak + * volume (Float, optional, default 1.0) — output volume 0.0–1.0 + */ + class Speak(private val activity: FragmentActivity) : BridgeFunction { + init { + TTSEngine.initialize(activity.applicationContext) + } + + override fun execute(parameters: Map): Map { + val text = parameters["text"] as? String + ?: return BridgeResponse.error("INVALID_PARAMS", "Missing required parameter: text") + val volume = (parameters["volume"] as? Number)?.toFloat() ?: 1.0f + + val queued = TTSEngine.speak(text, volume) + return BridgeResponse.success(mapOf("queued" to queued, "text" to text)) + } + } + + /** + * Return engine readiness state. + */ + class GetStatus(private val activity: FragmentActivity) : BridgeFunction { + init { + TTSEngine.initialize(activity.applicationContext) + } + + override fun execute(parameters: Map): Map { + return BridgeResponse.success( + mapOf( + "ready" to TTSEngine.isReady, + "version" to "1.0.0" + ) + ) + } + } +} diff --git a/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php b/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..143b9b4 --- /dev/null +++ b/packages/nbucic/audio-tts/resources/boost/guidelines/core.blade.php @@ -0,0 +1,61 @@ +## nbucic/audio-tts + +An Audio TTS plugin for Android + +### Installation + +```bash +composer require nbucic/audio-tts +``` + +### PHP Usage (Livewire/Blade) + +Use the `AudioTTS` facade: + +@verbatim + +use Nbucic\AudioTts\Facades\AudioTTS; + +// Execute the plugin functionality +$result = AudioTTS::execute(['option1' => 'value']); + +// Get the current status +$status = AudioTTS::getStatus(); + +@endverbatim + +### Available Methods + +- `AudioTTS::execute()`: Execute the plugin functionality +- `AudioTTS::getStatus()`: Get the current status + +### Events + +- `AudioTTSCompleted`: Listen with `#[OnNative(AudioTTSCompleted::class)]` + +@verbatim + +use Native\Mobile\Attributes\OnNative; +use Nbucic\AudioTts\Events\AudioTTSCompleted; + +#[OnNative(AudioTTSCompleted::class)] +public function handleAudioTTSCompleted($result, $id = null) +{ + // Handle the event +} + +@endverbatim + +### JavaScript Usage (Vue/React/Inertia) + +@verbatim + +import { audioTTS } from '@nbucic/audio-tts'; + +// Execute the plugin functionality +const result = await audioTTS.execute({ option1: 'value' }); + +// Get the current status +const status = await audioTTS.getStatus(); + +@endverbatim \ No newline at end of file diff --git a/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift b/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift new file mode 100644 index 0000000..639b39e --- /dev/null +++ b/packages/nbucic/audio-tts/resources/ios/AudioTTSFunctions.swift @@ -0,0 +1,27 @@ +import Foundation + +enum AudioTTSFunctions { + + class Execute: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + // TODO: Implement your native functionality here + let option1 = parameters["option1"] as? String ?? "" + + // Example: Return success with data + return BridgeResponse.success(data: [ + "result": "executed", + "option1": option1 + ]) + } + } + + class GetStatus: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + // TODO: Return current status + return BridgeResponse.success(data: [ + "status": "ready", + "version": "1.0.0" + ]) + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/resources/js/audioTTS.js b/packages/nbucic/audio-tts/resources/js/audioTTS.js new file mode 100644 index 0000000..1261d9d --- /dev/null +++ b/packages/nbucic/audio-tts/resources/js/audioTTS.js @@ -0,0 +1,82 @@ +/** + * AudioTTS Plugin for NativePHP Mobile + * + * @example + * import { audioTTS } from '@nbucic/audio-tts'; + * + * // Execute functionality + * const result = await audioTTS.execute({ option1: 'value' }); + * + * // Get status + * const status = await audioTTS.getStatus(); + */ + +const baseUrl = '/_native/api/call'; + +/** + * Internal bridge call function + * @private + */ +async function bridgeCall(method, params = {}) { + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '' + }, + body: JSON.stringify({ method, params }) + }); + + const result = await response.json(); + + if (result.status === 'error') { + throw new Error(result.message || 'Native call failed'); + } + + const nativeResponse = result.data; + if (nativeResponse && nativeResponse.data !== undefined) { + return nativeResponse.data; + } + + return nativeResponse; +} + +/** + * Speak text via Android TTS. + * Falls back to Web Speech API when the native bridge is unavailable (browser dev). + * @param {string} text - The phrase to speak + * @param {number} [volume=1.0] - Output volume 0.0–1.0 + * @returns {Promise} + */ +export async function speak(text, volume = 1.0) { + try { + console.log(`[TTS SPEAK] text: ${text}, volume: ${volume}`); + return await bridgeCall('AudioTTS.Speak', { text, volume }); + } catch (e) { + if ('speechSynthesis' in window) { + const utt = new SpeechSynthesisUtterance(text); + utt.volume = volume; + speechSynthesis.speak(utt); + return { queued: true, text, fallback: 'webSpeech' }; + } + throw e; + } +} + +/** + * Get the current status + * @returns {Promise} + */ +export async function getStatus() { + return bridgeCall('AudioTTS.GetStatus'); +} + +/** + * AudioTTS namespace object + */ +export const audioTTS = { + speak, + getStatus +}; + +export default audioTTS; diff --git a/packages/nbucic/audio-tts/src/AudioTTS.php b/packages/nbucic/audio-tts/src/AudioTTS.php new file mode 100644 index 0000000..4c8233a --- /dev/null +++ b/packages/nbucic/audio-tts/src/AudioTTS.php @@ -0,0 +1,40 @@ +data ?? null; + } + } + + return null; + } + + /** + * Execute the plugin functionality + */ + public function speak(string $text, float $volume = 1.0): mixed + { + if (function_exists('nativephp_call')) { + $result = nativephp_call('AudioTTS.Speak', json_encode(['text' => $text, 'volume' => $volume])); + + if ($result) { + $decoded = json_decode($result); + return $decoded->data ?? null; + } + } + + return null; + } +} diff --git a/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php b/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php new file mode 100644 index 0000000..30e84e8 --- /dev/null +++ b/packages/nbucic/audio-tts/src/AudioTTSServiceProvider.php @@ -0,0 +1,26 @@ +app->singleton(AudioTTS::class, function () { + return new AudioTTS(); + }); + } + + public function boot(): void + { + // Register plugin hook commands + if ($this->app->runningInConsole()) { + $this->commands([ + CopyAssetsCommand::class, + ]); + } + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php b/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php new file mode 100644 index 0000000..a37cdf9 --- /dev/null +++ b/packages/nbucic/audio-tts/src/Commands/CopyAssetsCommand.php @@ -0,0 +1,65 @@ +isAndroid()) { + $this->copyAndroidAssets(); + } + + if ($this->isIos()) { + $this->copyIosAssets(); + } + + return self::SUCCESS; + } + + /** + * Copy assets for Android build + */ + protected function copyAndroidAssets(): void + { + // Example: Copy a TensorFlow Lite model to Android assets + // $this->copyToAndroidAssets('model.tflite', 'model.tflite'); + + // Example: Download a model if not present locally + // $modelPath = $this->pluginPath() . '/resources/model.tflite'; + // $this->downloadIfMissing( + // 'https://example.com/model.tflite', + // $modelPath + // ); + // $this->copyToAndroidAssets('model.tflite', 'model.tflite'); + + $this->info('Android assets copied for AudioTTS'); + } + + /** + * Copy assets for iOS build + */ + protected function copyIosAssets(): void + { + // Example: Copy a Core ML model to iOS bundle + // $this->copyToIosBundle('model.mlmodelc', 'model.mlmodelc'); + + $this->info('iOS assets copied for AudioTTS'); + } +} \ No newline at end of file diff --git a/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php b/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php new file mode 100644 index 0000000..945030a --- /dev/null +++ b/packages/nbucic/audio-tts/src/Events/AudioTTSCompleted.php @@ -0,0 +1,16 @@ +in('.'); \ No newline at end of file diff --git a/packages/nbucic/audio-tts/tests/PluginTest.php b/packages/nbucic/audio-tts/tests/PluginTest.php new file mode 100644 index 0000000..bf364de --- /dev/null +++ b/packages/nbucic/audio-tts/tests/PluginTest.php @@ -0,0 +1,221 @@ +pluginPath = dirname(__DIR__); + $this->manifestPath = $this->pluginPath . '/nativephp.json'; +}); + +describe('Plugin Manifest', function () { + it('has a valid nativephp.json file', function () { + expect(file_exists($this->manifestPath))->toBeTrue(); + + $content = file_get_contents($this->manifestPath); + $manifest = json_decode($content, true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + }); + + it('has required fields', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest)->toHaveKeys(['name', 'namespace', 'bridge_functions']); + expect($manifest['name'])->toBe('nbucic/audio-tts'); + expect($manifest['namespace'])->toBe('AudioTTS'); + }); + + it('has valid bridge functions', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['bridge_functions'])->toBeArray(); + + foreach ($manifest['bridge_functions'] as $function) { + expect($function)->toHaveKeys(['name']); + expect($function)->toHaveAnyKeys(['android', 'ios']); + } + }); + + it('has valid marketplace metadata', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + // Optional but recommended for marketplace + if (isset($manifest['keywords'])) { + expect($manifest['keywords'])->toBeArray(); + } + + if (isset($manifest['category'])) { + expect($manifest['category'])->toBeString(); + } + + if (isset($manifest['platforms'])) { + expect($manifest['platforms'])->toBeArray(); + foreach ($manifest['platforms'] as $platform) { + expect($platform)->toBeIn(['android', 'ios']); + } + } + }); +}); + +describe('Native Code', function () { + it('has Android Kotlin file', function () { + $kotlinFile = $this->pluginPath . '/resources/android/AudioTTSFunctions.kt'; + + expect(file_exists($kotlinFile))->toBeTrue(); + + $content = file_get_contents($kotlinFile); + expect($content)->toContain('package com.nbucic.plugins.audio_tts'); + expect($content)->toContain('object AudioTTSFunctions'); + expect($content)->toContain('BridgeFunction'); + }); + + it('has iOS Swift file', function () { + $swiftFile = $this->pluginPath . '/resources/ios/AudioTTSFunctions.swift'; + + expect(file_exists($swiftFile))->toBeTrue(); + + $content = file_get_contents($swiftFile); + expect($content)->toContain('enum AudioTTSFunctions'); + expect($content)->toContain('BridgeFunction'); + }); + + it('has matching bridge function classes in native code', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + $kotlinFile = $this->pluginPath . '/resources/android/AudioTTSFunctions.kt'; + $swiftFile = $this->pluginPath . '/resources/ios/AudioTTSFunctions.swift'; + + $kotlinContent = file_get_contents($kotlinFile); + $swiftContent = file_get_contents($swiftFile); + + foreach ($manifest['bridge_functions'] as $function) { + // Extract class name from the function reference + if (isset($function['android'])) { + $parts = explode('.', $function['android']); + $className = end($parts); + expect($kotlinContent)->toContain("class {$className}"); + } + + if (isset($function['ios'])) { + $parts = explode('.', $function['ios']); + $className = end($parts); + expect($swiftContent)->toContain("class {$className}"); + } + } + }); +}); + +describe('PHP Classes', function () { + it('has service provider', function () { + $file = $this->pluginPath . '/src/AudioTTSServiceProvider.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts'); + expect($content)->toContain('class AudioTTSServiceProvider'); + }); + + it('has facade', function () { + $file = $this->pluginPath . '/src/Facades/AudioTTS.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts\Facades'); + expect($content)->toContain('class AudioTTS extends Facade'); + }); + + it('has main implementation class', function () { + $file = $this->pluginPath . '/src/AudioTTS.php'; + expect(file_exists($file))->toBeTrue(); + + $content = file_get_contents($file); + expect($content)->toContain('namespace Nbucic\AudioTts'); + expect($content)->toContain('class AudioTTS'); + }); +}); + +describe('Composer Configuration', function () { + it('has valid composer.json', function () { + $composerPath = $this->pluginPath . '/composer.json'; + expect(file_exists($composerPath))->toBeTrue(); + + $content = file_get_contents($composerPath); + $composer = json_decode($content, true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE); + expect($composer['type'])->toBe('nativephp-plugin'); + expect($composer['extra']['nativephp']['manifest'])->toBe('nativephp.json'); + }); +}); + +describe('Lifecycle Hooks', function () { + it('has valid hooks configuration', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + if (isset($manifest['hooks'])) { + expect($manifest['hooks'])->toBeArray(); + + $validHooks = ['pre_compile', 'post_compile', 'copy_assets', 'post_build']; + foreach (array_keys($manifest['hooks']) as $hook) { + expect($hook)->toBeIn($validHooks); + } + } + }); + + it('has copy_assets hook command', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + expect($manifest['hooks']['copy_assets'] ?? null)->not->toBeNull(); + + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + expect(file_exists($commandFile))->toBeTrue(); + }); + + it('copy_assets command extends NativePluginHookCommand', function () { + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + expect($content)->toContain('extends NativePluginHookCommand'); + expect($content)->toContain('use Native\Mobile\Plugins\Commands\NativePluginHookCommand'); + }); + + it('copy_assets command has correct signature', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + $expectedSignature = $manifest['hooks']['copy_assets']; + + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + expect($content)->toContain('$signature = \'' . $expectedSignature . '\''); + }); + + it('copy_assets command has platform-specific methods', function () { + $commandFile = $this->pluginPath . '/src/Commands/CopyAssetsCommand.php'; + $content = file_get_contents($commandFile); + + // Should check for platform + expect($content)->toContain('$this->isAndroid()'); + expect($content)->toContain('$this->isIos()'); + }); + + it('has valid assets configuration', function () { + $manifest = json_decode(file_get_contents($this->manifestPath), true); + + // Assets are at top level with android/ios nested inside + if (isset($manifest['assets'])) { + expect($manifest['assets'])->toBeArray(); + + if (isset($manifest['assets']['android'])) { + expect($manifest['assets']['android'])->toBeArray(); + } + + if (isset($manifest['assets']['ios'])) { + expect($manifest['assets']['ios'])->toBeArray(); + } + } + }); +}); \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index 8724cee..35c2c85 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,6 @@ import './bootstrap'; -import { initAudio } from './audio'; +import {initAudio} from './audio'; +import audioTTS from "../../packages/nbucic/audio-tts/resources/js/audioTTS.js"; // Boot Alpine after Livewire (Livewire v3 integrates automatically) document.addEventListener('alpine:init', () => { @@ -8,29 +9,40 @@ document.addEventListener('alpine:init', () => { audio: null, soundMode: 'beep', volume: 0.8, + keepScreenOn: true, interval: null, + _wakeLock: null, + init() { this.soundMode = this.$wire.soundMode; this.volume = this.$wire.volume; + this.keepScreenOn = this.$wire.keepScreenOn; this.program = this.$wire.program; this.audio = initAudio(this.volume); + console.log('[TTS] timerAudio init: soundMode=' + this.soundMode + + ', keepScreenOn=' + this.keepScreenOn + + ', AndroidTTS=' + (typeof audioTTS.speak === 'function')); + // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active - this.$watch('$wire.state', state => { - // Handle Livewire Enum serialization (v3) - const stateName = state; - console.log('State changed:', stateName); + this.$watch('$wire.state', async state => { + console.log('State changed:', state); clearInterval(this.interval); - if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) { + if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(state)) { this.interval = setInterval(() => this.$wire.tick(), 1000); + await this._acquireWakeLock(); + } else { + await this._releaseWakeLock(); } }); - this.$wire.on('playBeep', ({reason}) => { - console.log('playBeep', reason); + this.$wire.on('playBeep', async ({reason}) => { + console.log(`Beep, reason: ${reason}, mode: ${this.soundMode}`); if (this.soundMode === 'voice') { - this.audio.speak(this.voiceText(reason)); + const text = this.voiceText(reason); + console.log('[TTS] playBeep: reason=' + reason + ', voiceText="' + text + '"'); + await audioTTS.speak(text); } else if (reason === 'prepare') { this.audio.prepareBeep(); } else { @@ -49,18 +61,81 @@ document.addEventListener('alpine:init', () => { this.$wire.on('playPauseBeep', () => { this.audio.pauseBeep(); }); + + // Sync settings changes saved from the Settings screen + this.$wire.on('settingsLoaded', ({soundMode, volume, keepScreenOn}) => { + if (soundMode !== undefined) this.soundMode = soundMode; + if (volume !== undefined) { + this.volume = volume; + this.audio = initAudio(volume); + } + if (keepScreenOn !== undefined) this.keepScreenOn = keepScreenOn; + }); }, + voiceText(reason) { const map = { - prepare: this.$wire?.countdownLabel ?? '', - countdown: this.$wire?.countdownLabel ?? 'Get ready', - rep_end: 'Done', - pause_end: 'Go', + prepare: this.$wire?.countdownLabel ?? '', + countdown: this.$wire?.countdownLabel ?? 'Get ready', + rep_end: 'Done', + pause_end: 'Go', cooldown_end: 'Next', }; return map[reason] ?? 'Beep'; }, + + async _acquireWakeLock() { + if (!this.keepScreenOn) return; + if (!('wakeLock' in navigator)) return; + if (this._wakeLock) return; // already held + try { + this._wakeLock = await navigator.wakeLock.request('screen'); + console.log('Wake lock acquired'); + // Re-acquire automatically if the OS releases it (e.g., tab hidden then shown) + this._wakeLock.addEventListener('release', () => { + this._wakeLock = null; + }); + } catch (e) { + console.warn('Wake lock request failed:', e.message); + } + }, + + async _releaseWakeLock() { + if (!this._wakeLock) return; + try { + await this._wakeLock.release(); + console.log('Wake lock released'); + } catch (e) { + console.warn('Wake lock release failed:', e.message); + } + this._wakeLock = null; + }, })); + Alpine.data('settingsSounds', () => ({ + soundMode: 'beep', + volume: 0.8, + + init() { + this.soundMode = this.$wire.soundMode; + this.volume = this.$wire.volume; + this.audio = initAudio(1); + + this.$wire.on('playBeepSound', ({sound}) => { + console.log('Playing beep sound'); + if (sound === 'triple') { + this.audio.tripleBeep(); + } else { + this.audio.chime(); + } + }); + + this.$wire.on('play-TTS-Sound', ({text}) => { + console.log('Playing ho ho ho'); + audioTTS.speak(text).then(() => { + }); + }) + } + })) }); // Alpine is automatically started by Livewire v3. diff --git a/resources/js/audio.js b/resources/js/audio.js index 5e7a7cc..67ef860 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -1,13 +1,5 @@ -// noinspection JSUnresolvedReference - /** * Audio engine for the interval timer. - * - * soundMode = 'beep' → Web Audio API (synthesized, no network) - * soundMode = 'voice' → Android TTS via NativePHP JS bridge (feminine, calm) - * - * All methods are no-ops until the user has interacted with the page, - * satisfying the browser AudioContext autoplay policy. */ export function initAudio(volume = 0.8) { volume = Math.max(0, Math.min(1, volume)); @@ -15,7 +7,7 @@ export function initAudio(volume = 0.8) { function getCtx() { if (!ctx) { - ctx = new (window.AudioContext || window.webkitAudioContext)(); + ctx = new (window.AudioContext)(); } if (ctx.state === 'suspended') { ctx.resume(); @@ -25,13 +17,13 @@ export function initAudio(volume = 0.8) { /** Play a single short beep at the given frequency and duration. */ function tone(freq = 880, durationMs = 120, delayMs = 0) { - const c = getCtx(); - const osc = c.createOscillator(); - const gain = c.createGain(); - const start = c.currentTime + delayMs / 1000; - const end = start + durationMs / 1000; + const c = getCtx(); + const osc = c.createOscillator(); + const gain = c.createGain(); + const start = c.currentTime + delayMs / 1000; + const end = start + durationMs / 1000; - osc.type = 'sine'; + osc.type = 'sine'; osc.frequency.setValueAtTime(freq, start); gain.gain.setValueAtTime(volume * 0.6, start); gain.gain.exponentialRampToValueAtTime(0.001, end); @@ -43,9 +35,11 @@ export function initAudio(volume = 0.8) { } /** Single countdown beep (800 Hz, 100 ms). */ - function beep() { tone(800, 100); } + function beep() { + tone(800, 100); + } - /** Prepare-phase beep — three rapid beeps at the same tone (800 Hz, 100 ms × 3). */ + /** Prepare-phase beep -- three rapid beeps at the same tone (800 Hz, 100 ms x 3). */ function prepareBeep() { tone(800, 100, 0); tone(800, 100, 150); @@ -53,7 +47,9 @@ export function initAudio(volume = 0.8) { } /** Gentle single beep on user pause (600 Hz, 80 ms). */ - function pauseBeep() { tone(600, 80); } + function pauseBeep() { + tone(600, 80); + } /** * End sound: triple beep (three 880 Hz tones 150 ms apart). @@ -71,37 +67,8 @@ export function initAudio(volume = 0.8) { */ function chime() { tone(1046.5, 300, 0); // C6 - tone(880, 300, 120); // A5 - tone(698.5, 400, 240); // F5 - } - - /** - * Android TTS via NativePHP bridge. - * Falls back to Web Speech API on a web browser. - */ - function speak(text) { - if (window.NativePhp?.tts?.speak) { - // NativePHP Mobile TTS plugin (feminine, calm pitch) - window.NativePhp.tts.speak({ - text, - voice: 'female', - pitch: 0.9, - rate: 0.85, - }); - return; - } - // Browser fallback - if ('speechSynthesis' in window) { - const utt = new SpeechSynthesisUtterance(text); - utt.pitch = 0.9; - utt.rate = 0.85; - utt.volume = volume; - const voices = speechSynthesis.getVoices(); - const fem = voices.find(v => v.name.toLowerCase().includes('female')) - || voices.find(v => v.lang.startsWith('en')); - if (fem) utt.voice = fem; - speechSynthesis.speak(utt); - } + tone(880, 300, 120); // A5 + tone(698.5, 400, 240); // F5 } return { @@ -110,6 +77,5 @@ export function initAudio(volume = 0.8) { pauseBeep, tripleBeep, chime, - speak, }; } diff --git a/resources/views/livewire/program-editor.blade.php b/resources/views/livewire/program-editor.blade.php index 9b1cd4f..cd69113 100644 --- a/resources/views/livewire/program-editor.blade.php +++ b/resources/views/livewire/program-editor.blade.php @@ -251,19 +251,18 @@ class="w-full bg-gray-800 text-white rounded-xl px-4 py-3 {{ $this->editingIsLastPhase() ? 'text-gray-600' : 'text-gray-400' }}"> Cooldown (sec) -

+

@if($this->editingIsLastPhase()) - not counted — add another phase + The cooldown period for the last phase will not be counted in total duration @else After final rep @endif

editingIsLastPhase()) disabled @endif class="w-full rounded-xl px-4 py-3 border text-center text-lg font-bold focus:outline-none transition-colors {{ $this->editingIsLastPhase() - ? 'bg-gray-800/40 text-gray-600 border-white/5 cursor-not-allowed' + ? 'bg-gray-800/40 text-gray-600 border-white/5' : 'bg-gray-800 text-white border-white/10 focus:border-blue-500' }}"> diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 3398139..e47dd4a 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,5 +1,6 @@ @php use App\Enum\BeepLeadIn; @endphp -
+

Settings

@@ -7,7 +8,11 @@ wire:click="save" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold px-4 py-2 rounded-xl transition-colors" > - @if($saved) Saved ✓ @else Save @endif + @if($saved) + Saved ✓ + @else + Save + @endif
@@ -23,7 +28,7 @@ class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold px-4 py-2

Sound Mode

+ >3s + + >5s +
@@ -113,12 +122,14 @@ class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors wire:click="$set('defaultEndSound', 'triple')" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors {{ $defaultEndSound === 'triple' ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400' }}" - >Triple + >Triple + + >Chime +
diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php index b405b1f..c02093d 100644 --- a/resources/views/livewire/timer-screen.blade.php +++ b/resources/views/livewire/timer-screen.blade.php @@ -259,6 +259,12 @@ class="text-gray-800 text-xs mt-2 select-none" {{-- Primary action --}} @if($state === StateMachine::idle) + @if(count($phases) === 0) +
+ No phases — edit this program to add at least one phase before starting. +
+ @else + @endif @elseif($state === StateMachine::prepare) {{-- No primary action during prepare — user just waits --}} diff --git a/tmp/MainActivity.kt b/tmp/MainActivity.kt new file mode 100644 index 0000000..323f3a8 --- /dev/null +++ b/tmp/MainActivity.kt @@ -0,0 +1,1044 @@ +package com.nativephp.mobile.ui + +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.os.Bundle +import android.os.Looper +import android.os.Handler +import android.util.Log +import android.webkit.CookieManager +import androidx.fragment.app.FragmentActivity +import androidx.activity.compose.setContent +import com.nativephp.mobile.bridge.PHPBridge +import com.nativephp.mobile.bridge.PHPQueueWorker +import com.nativephp.mobile.bridge.LaravelEnvironment +import com.nativephp.mobile.bridge.registerBridgeFunctions +import com.nativephp.mobile.network.WebViewManager +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.addCallback +import com.nativephp.mobile.utils.NativeActionCoordinator +import com.nativephp.mobile.utils.WebViewProvider +import com.nativephp.mobile.security.LaravelCookieStore +import com.nativephp.mobile.lifecycle.NativePHPLifecycle +import java.io.File +import java.net.URL +import android.webkit.WebChromeClient +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.ime +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.graphics.Insets +import kotlinx.coroutines.launch + +class MainActivity : FragmentActivity(), WebViewProvider { + private lateinit var webView: WebView + private val phpBridge = PHPBridge(this) + private lateinit var laravelEnv: LaravelEnvironment + private lateinit var webViewManager: WebViewManager + private lateinit var coord: NativeActionCoordinator + private var pendingDeepLink: String? = null + private var hotReloadWatcherThread: Thread? = null + private var queueWorker: PHPQueueWorker? = null + private var shouldStopWatcher = false + private var pendingInsets: Insets? = null + private var showSplash by mutableStateOf(true) + + // Status bar style configuration - replaced during build + private val statusBarStyle = "auto" + + companion object { + // Static instance holder for accessing MainActivity from other activities + var instance: MainActivity? = null + private set + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + instance = this + + // Android 15 edge-to-edge compatibility fix + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Configure status bar icon colors + configureStatusBar() + + // Apply window insets - inject as CSS variables for web content + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + pendingInsets = systemBars + + // Inject CSS custom properties into WebView if ready + if (::webViewManager.isInitialized) { + injectSafeAreaInsets(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + } + + // Detect keyboard visibility and inject class into WebView + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + if (::webViewManager.isInitialized) { + injectKeyboardVisibility(imeVisible) + } + + insets + } + + // Initialize WebView before setContent so it's available for composition + webView = WebView(this).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.mediaPlaybackRequiresUserGesture = false + } + + LaravelCookieStore.init(applicationContext) + + // Register bridge functions early, before PHP code can execute + Log.d("MainActivity", "🔌 Registering bridge functions...") + registerBridgeFunctions(this, applicationContext) + Log.d("MainActivity", "✅ Bridge functions registered") + + handleDeepLinkIntent(intent) + + // Set up Compose UI + setContent { + MainScreen() + } + + initializeEnvironmentAsync { + // Setup WebView and managers FIRST + webViewManager = WebViewManager(this, webView, phpBridge) + webViewManager.setup() + coord = NativeActionCoordinator.install(this) + + // Add JavaScript interface for drawer control + webView.addJavascriptInterface(AndroidBridge(), "AndroidBridge") + + // Inject safe area insets BEFORE loading any URL to prevent content shift + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + + // NOW load the URL after WebView is fully configured + val target = pendingDeepLink ?: LaravelEnvironment.getStartURL(this) + val fullUrl = "http://127.0.0.1$target" + Log.d("DeepLink", "🚀 Loading final URL after WebView setup: $fullUrl") + webView.loadUrl(fullUrl) + + pendingDeepLink = null + + // Hide splash screen after URL is loaded + showSplash = false + + // Start hot reload watcher AFTER Laravel environment is initialized + startHotReloadWatcher() + injectJavaScript(webView) + } + + onBackPressedDispatcher.addCallback(this) { + if (webView.canGoBack()) { + webView.goBack() + } else { + finish() + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Log.d("MainActivity", "🌀 Config changed: orientation = ${newConfig.orientation}") + + // Re-inject safe area insets on orientation change + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + + // Reconfigure status bar on theme change + if ((newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) != 0) { + configureStatusBar() + } + } + + /** + * Configure status bar and navigation bar colors and icon appearance based on config + * - auto: Detect from system theme (light icons in dark mode, dark icons in light mode) + * - light: Always use light/white icons + * - dark: Always use dark icons + * + * For edge-to-edge mode, system bars are transparent to allow content to draw behind them + */ + @Suppress("DEPRECATION") + private fun configureStatusBar() { + val windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) + + // Make status bar and navigation bar transparent for edge-to-edge + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + when (statusBarStyle) { + "auto" -> { + // Auto-detect from system theme + val isSystemDarkMode = (resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + + // Light status/nav bars (dark icons) for light theme + // Dark status/nav bars (light icons) for dark theme + windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode + windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode + + Log.d("StatusBar", "🎨 System bars style: auto (system ${if (isSystemDarkMode) "dark" else "light"} mode)") + Log.d("StatusBar", "🎨 Using ${if (!isSystemDarkMode) "dark" else "light"} icons with transparent background") + } + "light" -> { + // Light/white icons (for dark backgrounds) + windowInsetsController.isAppearanceLightStatusBars = false + windowInsetsController.isAppearanceLightNavigationBars = false + + Log.d("StatusBar", "🎨 System bars style: light (white icons with transparent background)") + } + "dark" -> { + // Dark icons (for light backgrounds) + windowInsetsController.isAppearanceLightStatusBars = true + windowInsetsController.isAppearanceLightNavigationBars = true + + Log.d("StatusBar", "🎨 System bars style: dark (dark icons with transparent background)") + } + else -> { + Log.w("StatusBar", "⚠️ Unknown status bar style: $statusBarStyle, defaulting to auto") + // Default to auto + val isSystemDarkMode = (resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + windowInsetsController.isAppearanceLightStatusBars = !isSystemDarkMode + windowInsetsController.isAppearanceLightNavigationBars = !isSystemDarkMode + } + } + } + + private fun initializeEnvironmentAsync(onReady: () -> Unit) { + Thread { + Log.d("LaravelInit", "Starting async Laravel extraction...") + laravelEnv = LaravelEnvironment(this) + laravelEnv.initialize() + + Log.d("LaravelInit", "Laravel environment ready") + + // Check runtime mode from bundle_meta.json + val runtimeMode = LaravelEnvironment.getRuntimeMode(this) + Log.d("LaravelInit", "Runtime mode: $runtimeMode") + + if (runtimeMode == "classic") { + Log.d("LaravelInit", "Classic mode configured — skipping persistent runtime boot") + } else { + // Boot persistent PHP runtime BEFORE WebView loads + // This boots Laravel once — all subsequent requests dispatch through the live interpreter + val bootStart = System.currentTimeMillis() + val booted = phpBridge.bootPersistentRuntime() + val bootTime = System.currentTimeMillis() - bootStart + + if (booted) { + Log.d("LaravelInit", "Persistent runtime booted in ${bootTime}ms — requests will skip init/shutdown") + + // Start background queue worker after persistent runtime is ready + queueWorker = PHPQueueWorker(phpBridge).also { it.start() } + } else { + Log.w("LaravelInit", "Persistent runtime boot failed after ${bootTime}ms — falling back to classic mode") + } + } + + Handler(Looper.getMainLooper()).post { + onReady() + } + }.start() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleDeepLinkIntent(intent) + + // If deep link didn't fire but we have a notification URL, navigate via Inertia + if (intent.data == null) { + val notificationUrl = intent.getStringExtra("notification_url") + if (!notificationUrl.isNullOrEmpty()) { + navigateWithInertia(notificationUrl) + } + } + + // Post lifecycle event for plugins + intent.data?.let { uri -> + NativePHPLifecycle.post( + NativePHPLifecycle.Events.ON_NEW_INTENT, + mapOf("url" to uri.toString()) + ) + } + } + + override fun onResume() { + super.onResume() + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_RESUME) + } + + override fun onPause() { + super.onPause() + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_PAUSE) + } + + private fun handleDeepLinkIntent(intent: Intent?) { + // Check for notification URL extra (from local notification taps or foreground push) + val notificationUrl = intent?.getStringExtra("notification_url") + if (!notificationUrl.isNullOrEmpty()) { + Log.d("DeepLink", "🔔 Notification URL: $notificationUrl") + pendingDeepLink = notificationUrl + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + val fullUrl = "http://127.0.0.1$notificationUrl" + Log.d("DeepLink", "🚀 Loading notification URL immediately: $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } + return + } + + // Check for deep link URL from FCM data payload (background/killed push notifications) + val fcmUrl = intent?.getStringExtra("url") ?: intent?.getStringExtra("link") + if (!fcmUrl.isNullOrEmpty()) { + Log.d("DeepLink", "🔔 FCM deep link URL: $fcmUrl") + val uri = android.net.Uri.parse(fcmUrl) + val scheme = uri.scheme + val route = if (scheme != null && scheme != "http" && scheme != "https") { + val host = uri.host ?: "" + val path = uri.path ?: "" + val query = uri.query?.let { "?$it" } ?: "" + if (host.isNotEmpty()) "/$host$path$query" else "$path$query" + } else { + fcmUrl + } + pendingDeepLink = route + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + val fullUrl = "http://127.0.0.1$route" + Log.d("DeepLink", "🚀 Loading FCM deep link immediately: $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } + return + } + + val uri = intent?.data ?: return + Log.d("DeepLink", "🌐 Received deep link: $uri") + + // Check if this is an OAuth callback from nativephp:// scheme + if (uri.scheme == "nativephp") { + Log.d("OAuth", "🔐 OAuth callback detected from scheme: ${uri.scheme}") + Log.d("OAuth", "🔐 OAuth callback host: ${uri.host}") + Log.d("OAuth", "🔐 OAuth callback path: ${uri.path}") + Log.d("OAuth", "🔐 OAuth callback query: ${uri.query}") + + // Check for common OAuth parameters + val code = uri.getQueryParameter("code") + val state = uri.getQueryParameter("state") + val error = uri.getQueryParameter("error") + + if (code != null) { + Log.d("OAuth", "✅ OAuth authorization code received: ${code.take(10)}...") + } + if (state != null) { + Log.d("OAuth", "✅ OAuth state parameter: $state") + } + if (error != null) { + Log.e("OAuth", "❌ OAuth error received: $error") + } + } + + val query = uri.query + val laravelUrl = if (uri.scheme != "http" && uri.scheme != "https") { + // Custom scheme (e.g., myapp://profile/settings): treat host as first path segment + // This matches iOS behavior where the entire URI after scheme:// is the path + val host = uri.host ?: "" + val path = uri.path ?: "" + buildString { + if (host.isNotEmpty()) append("/$host") + if (path.isNotEmpty()) append(path) else if (host.isEmpty()) append("/") + if (!query.isNullOrBlank()) append("?$query") + } + } else { + // HTTP(S) app links: just use the path (host is the verified domain) + buildString { + append(uri.path ?: "/") + if (!query.isNullOrBlank()) append("?$query") + } + } + + Log.d("DeepLink", "📦 Saving deep link for later: $laravelUrl") + pendingDeepLink = laravelUrl + if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) { + // Only load immediately if both Laravel environment AND WebView are ready + val fullUrl = "http://127.0.0.1$laravelUrl" + Log.d("DeepLink", "🚀 Loading deep link immediately (app already running): $fullUrl") + webView.loadUrl(fullUrl) + pendingDeepLink = null + } else { + Log.d("DeepLink", "⏳ Deep link saved, waiting for app initialization to complete") + } + } + + + private fun initializeEnvironment() { + clearAllCookies() + laravelEnv = LaravelEnvironment(this) + laravelEnv.initialize() + + } + + fun clearAllCookies() { + val cookieManager = CookieManager.getInstance() + cookieManager.removeAllCookies(null) + cookieManager.flush() + Log.d("CookieInfo", "All cookies cleared") + } + + + override fun onDestroy() { + super.onDestroy() + instance = null + + // Post lifecycle event for plugins + NativePHPLifecycle.post(NativePHPLifecycle.Events.ON_DESTROY) + + // Clean up coordinator fragment to prevent memory leaks + if (::coord.isInitialized) { + supportFragmentManager.beginTransaction() + .remove(coord) + .commitNowAllowingStateLoss() + } + + if (::webViewManager.isInitialized) { + val chromeClient = webView.webChromeClient + if (chromeClient is WebChromeClient) { + chromeClient.onHideCustomView() + } + webViewManager.shutdown() + } + + // Stop hot reload watcher thread + shouldStopWatcher = true + hotReloadWatcherThread?.interrupt() + + // Stop background queue worker before persistent runtime shutdown + queueWorker?.stop() + + // Shutdown persistent runtime before cleanup + if (phpBridge.isPersistentMode()) { + phpBridge.shutdownPersistentRuntime() + } + + laravelEnv.cleanup() + phpBridge.shutdown() + } + + override fun getWebView(): WebView { + return webView + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + // Post lifecycle event for each permission result + permissions.forEachIndexed { index, permission -> + val granted = grantResults.getOrNull(index) == PackageManager.PERMISSION_GRANTED + NativePHPLifecycle.post( + NativePHPLifecycle.Events.ON_PERMISSION_RESULT, + mapOf( + "permission" to permission, + "granted" to granted, + "requestCode" to requestCode + ) + ) + } + + when (requestCode) { + 1001 -> { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Log.d("Permission", "✅ Location permission granted") + // Optionally re-trigger the location fetch + } else { + Log.e("Permission", "❌ Location permission denied") + } + } + 1002 -> { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Log.d("Permission", "✅ Push notification permission granted") + } else { + Log.e("Permission", "❌ Push notification permission denied") + } + } + } + } + + private fun startHotReloadWatcher() { + if (!isDebugVersion()) return + + // Configure WebView for development - disable caching for hot reload + webView.settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE + + hotReloadWatcherThread = Thread { + val appStorageDir = File(filesDir.parent, "app_storage") + val reloadFile = File("${appStorageDir.absolutePath}/laravel/storage/framework/reload_signal.json") + var lastModified: Long = 0 + + while (!shouldStopWatcher && !Thread.currentThread().isInterrupted) { + try { + if (reloadFile.exists() && reloadFile.lastModified() > lastModified) { + lastModified = reloadFile.lastModified() + + runOnUiThread { + webView.stopLoading() + webView.clearCache(true) + webView.clearHistory() + webView.clearFormData() + + val currentUrl = webView.url ?: "http://127.0.0.1/" + val separator = if (currentUrl.contains("?")) "&" else "?" + val cacheBustUrl = "${currentUrl}${separator}_cb=${System.currentTimeMillis()}" + + Handler(Looper.getMainLooper()).postDelayed({ + webView.loadUrl(cacheBustUrl) + }, 100) + } + } + + Thread.sleep(500) + } catch (e: InterruptedException) { + break + } catch (e: Exception) { + Log.e("HotReload", "Watcher error: ${e.message}", e) + Thread.sleep(1000) + } + } + } + hotReloadWatcherThread?.start() + } + + private fun isDebugVersion(): Boolean { + return try { + val appStorageDir = File(filesDir.parent, "app_storage") + val versionFile = File(appStorageDir, "laravel/.version") + + if (versionFile.exists()) { + val version = versionFile.readText().trim().trim('"').trim('\'') + version.equals("DEBUG", ignoreCase = true) + } else { + false + } + } catch (e: Exception) { + false + } + } + + + private fun injectJavaScript(view: WebView) { + val jsCode = """ + (function() { + // Add platform identifier class + document.body.classList.add('nativephp-android'); + + // 🌐 Native event bridge + const listeners = {}; + + const Native = { + on: function(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + off: function(eventName, callback) { + if (listeners[eventName]) { + listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); + } + }, + dispatch: function(eventName, payload) { + const cbs = listeners[eventName] || []; + cbs.forEach(cb => cb(payload, eventName)); + }, + openDrawer: function() { + if (window.AndroidBridge) { + window.AndroidBridge.openDrawer(); + } + } + }; + + window.Native = Native; + + document.addEventListener("native-event", function (e) { + // Normalize event names by removing leading backslashes + let eventName = e.detail.event.replace(/^(\\\\)+/, ''); + const payload = e.detail.payload; + + // Dispatch with normalized event name + Native.dispatch(eventName, payload); + + // Also dispatch to Livewire if available + if (window.Livewire && typeof window.Livewire.dispatch === 'function') { + window.Livewire.dispatch('native:' + eventName, payload); + } + }); + })(); + """ + view.evaluateJavascript(jsCode, null) + } + + private fun injectSafeAreaInsets(left: Int, top: Int, right: Int, bottom: Int) { + val density = resources.displayMetrics.density + val displayMetrics = resources.displayMetrics + + // Get current screen dimensions (rotated) + val currentWidthPx = (displayMetrics.widthPixels / density).toInt() + val currentHeightPx = (displayMetrics.heightPixels / density).toInt() + + // Determine natural (portrait) dimensions + // The smaller dimension is always the width in portrait orientation + val portraitWidthPx = minOf(currentWidthPx, currentHeightPx) + val portraitHeightPx = maxOf(currentWidthPx, currentHeightPx) + + val leftPx = (left / density).toInt() + var topPx = (top / density).toInt() + val rightPx = (right / density).toInt() + val bottomPx = (bottom / density).toInt() + + // Check if native top bar is present - if so, set top inset to 0 + // The native top bar already handles status bar spacing + val hasTopBar = NativeUIState.topBarData.value != null + if (hasTopBar) { + topPx = 0 + Log.d("SafeArea", "Native top bar detected - setting top inset to 0") + } + + // Get actual device orientation from Android Configuration + val isPortrait = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + + Log.d("SafeArea", "Device orientation: ${if (isPortrait) "Portrait" else "Landscape"}") + Log.d("SafeArea", "Current screen dimensions: ${currentWidthPx}x${currentHeightPx}") + Log.d("SafeArea", "Natural (portrait) dimensions: ${portraitWidthPx}x${portraitHeightPx}") + Log.d("SafeArea", "Injecting insets: top=${topPx}px, right=${rightPx}px, bottom=${bottomPx}px, left=${leftPx}px") + + // Inject CSS as early as possible - create a self-executing function that runs immediately + // and also sets up listeners for Livewire navigation to persist styles + val jsCode = """ + (function() { + function injectSafeAreaStyles() { + // Remove existing safe-area style to avoid duplicates + const existingStyle = document.getElementById('nativephp-safe-area-style'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create style element with inset CSS variables and helper class + const style = document.createElement('style'); + style.id = 'nativephp-safe-area-style'; + style.setAttribute('data-nativephp-persist', 'true'); + style.textContent = ':root { --inset-top: ${topPx}px; --inset-right: ${rightPx}px; --inset-bottom: ${bottomPx}px; --inset-left: ${leftPx}px; } .nativephp-safe-area { ${if (isPortrait) "padding-top: var(--inset-top); padding-bottom: var(--inset-bottom);" else "padding-right: var(--inset-right); padding-left: var(--inset-left);"} }'; + + // Try to insert into head, or create head if it doesn't exist yet + if (!document.head) { + const head = document.createElement('head'); + if (document.documentElement) { + document.documentElement.insertBefore(head, document.documentElement.firstChild); + } + } + + if (document.head) { + // Insert at the BEGINNING of head for highest priority + if (document.head.firstChild) { + document.head.insertBefore(style, document.head.firstChild); + } else { + document.head.appendChild(style); + } + } + + // Also set CSS variables directly on documentElement for immediate availability + // These persist across Livewire navigate because html element is not replaced + if (document.documentElement) { + document.documentElement.style.setProperty('--inset-top', '${topPx}px'); + document.documentElement.style.setProperty('--inset-right', '${rightPx}px'); + document.documentElement.style.setProperty('--inset-bottom', '${bottomPx}px'); + document.documentElement.style.setProperty('--inset-left', '${leftPx}px'); + + // Add orientation class to HTML element for Tailwind targeting + document.documentElement.classList.remove('portrait', 'landscape'); + document.documentElement.classList.add('${if (isPortrait) "portrait" else "landscape"}'); + } + + console.log('SafeArea injected at ' + document.readyState + ': ${if (isPortrait) "portrait" else "landscape"}'); + } + + // Inject immediately + injectSafeAreaStyles(); + + // Re-inject when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectSafeAreaStyles); + } + + // IMPORTANT: Re-inject after Livewire navigation to persist styles + // Livewire can swap out the content during navigate: true transitions + document.addEventListener('livewire:navigated', function() { + console.log('Livewire navigated - re-injecting safe area styles'); + injectSafeAreaStyles(); + }); + + // Also listen for the older wire:navigate event (Livewire 2.x compatibility) + document.addEventListener('wire:navigate', function() { + console.log('Wire navigate - re-injecting safe area styles'); + injectSafeAreaStyles(); + }); + })(); + """ + webView.evaluateJavascript(jsCode, null) + } + + // Public function called by WebViewManager on page load + fun injectSafeAreaInsetsToWebView() { + pendingInsets?.let { + injectSafeAreaInsets(it.left, it.top, it.right, it.bottom) + } + } + + // Track keyboard visibility state to avoid redundant JS calls + private var lastKeyboardVisible: Boolean? = null + + private fun injectKeyboardVisibility(isVisible: Boolean) { + // Only inject if state actually changed + if (lastKeyboardVisible == isVisible) return + lastKeyboardVisible = isVisible + + // Update UI state so Compose components can react (e.g., hide bottom nav) + NativeUIState.setKeyboardVisible(isVisible) + + val jsCode = if (isVisible) { + "document.body.classList.add('keyboard-visible');" + } else { + "document.body.classList.remove('keyboard-visible');" + } + webView.evaluateJavascript(jsCode, null) + Log.d("Keyboard", "⌨️ Keyboard visibility changed: $isVisible") + } + + /** + * Extract path and query from URL, handling both full URLs and relative paths + * Supports Laravel route() helper output and relative paths + */ + private fun extractPath(url: String): String { + Log.d("Navigation", "📥 Received URL: $url") + + return try { + if (url.startsWith("http://") || url.startsWith("https://")) { + // Parse as full URL and extract path + query + val parsedUrl = URL(url) + // URL.getPath() returns empty string for root, not null - handle both cases + val path = if (parsedUrl.path.isNullOrEmpty()) "/" else parsedUrl.path + val query = parsedUrl.query + val result = if (query != null) "$path?$query" else path + Log.d("Navigation", "✅ Extracted path from full URL: $result") + result + } else if (url.startsWith("/")) { + // Already a path + Log.d("Navigation", "✅ Using path as-is: $url") + url + } else { + // Relative path, prepend / + val result = "/$url" + Log.d("Navigation", "✅ Converted relative to absolute: $result") + result + } + } catch (e: Exception) { + Log.e("Navigation", "❌ Error parsing URL: $url", e) + // Fallback: treat as relative path + val fallback = if (url.startsWith("/")) url else "/$url" + Log.d("Navigation", "🔄 Using fallback: $fallback") + fallback + } + } + + /** + * Navigate using Inertia router if available, otherwise fall back to direct navigation. + * This allows native edge component clicks to integrate with Inertia.js for SPA-like + * navigation while maintaining compatibility with non-Inertia apps. + */ + private fun navigateWithInertia(url: String) { + val path = extractPath(url) + Log.d("Navigation", "🚀 Navigating with Inertia check: $path") + + // Escape the path for JavaScript string (use double quotes to avoid issues with /) + val escapedPath = path.replace("\\", "\\\\").replace("\"", "\\\"") + + val jsCode = """ + (function() { + var path = "$escapedPath"; + console.log('[NativePHP] Navigation requested:', path); + + // Check if Inertia router is available + if (typeof window.router !== 'undefined' && typeof window.router.visit === 'function') { + console.log('[NativePHP] Using Inertia router.visit():', path); + window.router.visit(path); + } else { + console.log('[NativePHP] Inertia not available, using location.href'); + window.location.href = path; + } + })(); + """.trimIndent() + + webView.evaluateJavascript(jsCode, null) + } + + /** + * Main Compose UI screen with WebView, navigation, and overlays + * Side drawer wraps everything to avoid touch blocking issues + */ + @Composable + private fun MainScreen() { + Box(Modifier.fillMaxSize()) { + // Side drawer wraps the main content (correct ModalNavigationDrawer usage) + SideDrawerContent( + content = { + // Get FAB position from state + val fabData by NativeUIState.fabData + val fabPosition = when (fabData?.position?.lowercase()) { + "center" -> FabPosition.Center + "start" -> FabPosition.Start + else -> FabPosition.End // Default to end (bottom-right) + } + + // Scaffold provides standard Material3 layout with FAB support + // Configure for edge-to-edge by using zero content window insets + Scaffold( + topBar = { + NativeTopBar( + onMenuClick = { + Log.d("Navigation", "🍔 Menu button clicked - opening drawer") + }, + onNavigate = { url -> + Log.d("Navigation", "⚡ TopBar action navigation clicked") + navigateWithInertia(url) + } + ) + }, + bottomBar = { + BottomNavigationContent() + }, + floatingActionButton = { + NativeFab( + onNavigate = { url -> + Log.d("Navigation", "🖱️ FAB navigation clicked") + navigateWithInertia(url) + }, + onEvent = { eventName -> + Log.d("NativeEvent", "🖱️ FAB event dispatched: $eventName") + // Dispatch native event via JavaScript + val jsCode = """ + if (window.Native) { + window.Native.dispatch('$eventName', {}); + } + """.trimIndent() + webView.evaluateJavascript(jsCode, null) + } + ) + }, + floatingActionButtonPosition = fabPosition, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { paddingValues -> + // Main content: WebView only + // Use paddingValues to respect TopBar and BottomNav heights + // IMPORTANT: Add IME (keyboard) inset padding so content isn't hidden behind keyboard + + AndroidView( + factory = { webView }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .windowInsetsPadding(WindowInsets.ime), + update = { view -> + // Force layout recalculation when Compose size changes + // This ensures viewport units (100vh, 100vw) work correctly + view.requestLayout() + } + ) + } + } + ) + + // Splash overlay with fade animation (full screen, no insets) + AnimatedVisibility( + visible = showSplash, + exit = fadeOut(animationSpec = tween(300)) + ) { + SplashScreen() + } + } + } + + /** + * Splash screen composable - shows custom image or fallback text + */ + @Composable + private fun SplashScreen() { + val splashResourceId = remember { + try { + resources.getIdentifier("splash", "drawable", packageName) + } catch (e: Exception) { + 0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + if (splashResourceId != 0) { + Image( + painter = painterResource(id = splashResourceId), + contentDescription = "App splash screen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + SplashText() + } + } + } + + @Composable + private fun SplashText() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = "Loading…", + fontSize = 16.sp, + color = Color.White, + modifier = Modifier.padding(bottom = 64.dp) + ) + } + } + + /** + * Bottom navigation composable + * Hides with animation when keyboard is visible to prevent layout conflicts + */ + @Composable + private fun BottomNavigationContent() { + val isKeyboardVisible by NativeUIState.isKeyboardVisible + val bottomNavData by NativeUIState.bottomNavData + + val systemInDarkMode = isSystemInDarkTheme() + val useDarkTheme = bottomNavData?.dark ?: systemInDarkMode + val colorScheme = if (useDarkTheme) darkColorScheme() else lightColorScheme() + + // Animate bottom nav visibility - slide down when keyboard opens + AnimatedVisibility( + visible = !isKeyboardVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(150) + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(150) + ) + ) { + MaterialTheme(colorScheme = colorScheme) { + NativeBottomNavigation( + onNavigate = { url -> + Log.d("Navigation", "🖱️ Bottom nav item clicked") + navigateWithInertia(url) + } + ) + } + } + } + + /** + * Side drawer composable - wraps main content in ModalNavigationDrawer + */ + @Composable + private fun SideDrawerContent(content: @Composable () -> Unit) { + val systemInDarkMode = isSystemInDarkTheme() + val sideNavData by NativeUIState.sideNavData + val useDarkTheme = sideNavData?.dark ?: systemInDarkMode + val colorScheme = if (useDarkTheme) darkColorScheme() else lightColorScheme() + + MaterialTheme(colorScheme = colorScheme) { + NativeSideDrawer( + onNavigate = { url -> + Log.d("Navigation", "🖱️ Side nav item clicked") + navigateWithInertia(url) + }, + onDrawerStateChange = { isOpen -> + Log.d("SideDrawer", "Drawer state changed: $isOpen") + }, + content = content + ) + } + } + + inner class AndroidBridge { + @android.webkit.JavascriptInterface + fun openDrawer() { + Log.d("AndroidBridge", "🖱️ openDrawer() called from JavaScript") + runOnUiThread { + // Check if we have side nav data first + val hasData = NativeUIState.sideNavData.value != null && + !NativeUIState.sideNavData.value?.children.isNullOrEmpty() + + if (!hasData) { + Log.w("AndroidBridge", "⚠️ Cannot open drawer - no side nav data available") + return@runOnUiThread + } + + if (NativeUIState.drawerScope == null) { + Log.e("AndroidBridge", "❌ drawerScope is null!") + return@runOnUiThread + } + if (NativeUIState.drawerState == null) { + Log.e("AndroidBridge", "❌ drawerState is null!") + return@runOnUiThread + } + + // Open drawer via Compose state + NativeUIState.drawerScope?.launch { + NativeUIState.drawerState?.open() + Log.d("AndroidBridge", "✅ Drawer opened!") + } + } + } + } + +} \ No newline at end of file diff --git a/tmp/TTSBridge.kt b/tmp/TTSBridge.kt new file mode 100644 index 0000000..a3d9a96 --- /dev/null +++ b/tmp/TTSBridge.kt @@ -0,0 +1,174 @@ +package com.nativephp.mobile.bridge + +import android.content.Context +import android.media.MediaPlayer +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import android.webkit.JavascriptInterface +import java.io.File +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * JavaScript interface that exposes Android TextToSpeech to the WebView. + * + * Fixed phrases are pre-synthesized to WAV files in the app cache directory + * on first use and replayed via MediaPlayer on subsequent calls, eliminating + * TTS engine startup latency during workouts. + * + * Cache TTL: CACHE_DAYS days. Stale or missing files are rebuilt automatically. + * + * Registered as window.AndroidTTS in the WebView. + * + * Threading notes: + * - @JavascriptInterface methods are called on a binder thread. + * - MediaPlayer playback is dispatched to the main thread via mainHandler. + * - TextToSpeech callbacks arrive on the main thread. + */ +class TTSBridge(private val context: Context) { + + private companion object { + const val TAG = "TTSBridge" + const val CACHE_DAYS = 3L + + val KNOWN_PHRASES = listOf( + "Done", "Go", "Next", "Get ready", + "3", "2", "1", + "Rest", "Work", "Prepare", + ) + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private var tts: TextToSpeech? = null + private val players = ConcurrentHashMap() + private var engineReady = false + + init { + tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + tts?.language = Locale.US + tts?.setPitch(0.9f) + tts?.setSpeechRate(0.85f) + engineReady = true + setupUtteranceListener() + prebuildCache() + Log.d(TAG, "[TTS] engine ready") + } else { + Log.e(TAG, "[TTS] engine init failed with status $status") + } + } + } + + private fun cacheFile(phrase: String): File { + val safeName = phrase.lowercase().replace(Regex("[^a-z0-9]"), "_") + return File(context.cacheDir, "tts_$safeName.wav") + } + + private fun isCacheStale(file: File): Boolean { + if (!file.exists()) return true + val cutoffMs = System.currentTimeMillis() - CACHE_DAYS * 86_400_000L + return file.lastModified() < cutoffMs + } + + private fun setupUtteranceListener() { + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onDone(utteranceId: String) { + val file = cacheFile(utteranceId) + if (file.exists() && file.length() > 0) { + try { + val player = MediaPlayer().apply { + setDataSource(file.absolutePath) + prepare() + } + players[utteranceId] = player + Log.d(TAG, "[TTS] cached and loaded player for: $utteranceId") + } catch (e: Exception) { + Log.e(TAG, "[TTS] failed to load MediaPlayer for $utteranceId: ${e.message}") + } + } + } + override fun onError(utteranceId: String) { + Log.e(TAG, "[TTS] synthesis error for: $utteranceId") + } + override fun onStart(utteranceId: String) {} + }) + } + + private fun prebuildCache() { + Log.d(TAG, "[TTS] prebuildCache: pre-building ${KNOWN_PHRASES.size} phrases") + KNOWN_PHRASES.forEach { phrase -> + val file = cacheFile(phrase) + if (isCacheStale(file)) { + Log.d(TAG, "[TTS] synthesizing to file: $phrase") + val params = Bundle() + tts?.synthesizeToFile(phrase, params, file, phrase) + } else { + try { + players[phrase] = MediaPlayer().apply { + setDataSource(file.absolutePath) + prepare() + } + Log.d(TAG, "[TTS] loaded cached player for: $phrase") + } catch (e: Exception) { + Log.e(TAG, "[TTS] corrupt cache for $phrase, re-synthesizing: ${e.message}") + file.delete() + val params = Bundle() + tts?.synthesizeToFile(phrase, params, file, phrase) + } + } + } + } + + @JavascriptInterface + fun speak(text: String) { + Log.d(TAG, "[TTS] speak() entry: \"$text\"") + + val player = players[text] + if (player != null) { + mainHandler.post { + try { + player.seekTo(0) + player.start() + Log.d(TAG, "[TTS] playing cached TTS: $text") + } catch (e: Exception) { + Log.w(TAG, "[TTS] cached player failed for '$text', falling back: ${e.message}") + players.remove(text) + speakLive(text) + } + } + return + } + + Log.d(TAG, "[TTS] cache miss for \"$text\", calling speakLive()") + speakLive(text) + } + + private fun speakLive(text: String) { + if (tts == null) { + Log.e(TAG, "[TTS] speakLive: tts is null, cannot speak \"$text\"") + return + } + if (engineReady) { + Log.d(TAG, "[TTS] live TTS: $text") + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) + } else { + Log.w(TAG, "[TTS] engine not ready, dropping: $text") + } + } + + fun shutdown() { + mainHandler.post { + players.values.forEach { + try { it.release() } catch (_: Exception) {} + } + players.clear() + } + tts?.shutdown() + tts = null + engineReady = false + } +} diff --git a/tmp/WebViewManager.kt b/tmp/WebViewManager.kt new file mode 100644 index 0000000..6704983 --- /dev/null +++ b/tmp/WebViewManager.kt @@ -0,0 +1,584 @@ +package com.nativephp.mobile.network + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.webkit.* +import android.widget.Toast +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.content.pm.ActivityInfo +import android.app.Activity +import com.acsbendi.requestinspectorwebview.RequestInspectorWebViewClient +import com.nativephp.mobile.bridge.PHPBridge +import com.nativephp.mobile.ui.MainActivity +import com.nativephp.mobile.ui.NativeUIState +import org.json.JSONObject +import com.nativephp.mobile.security.LaravelSecurity +import com.nativephp.mobile.bridge.TTSBridge + +class WebViewManager( + private val context: Context, + private val webView: WebView, + private val phpBridge: PHPBridge +) { + private val TAG = "PHPMonitor" + private var fullscreenView: View? = null + private var customViewCallback: WebChromeClient.CustomViewCallback? = null + private val ttsBridge = TTSBridge(context) + + companion object { + var shared: WebViewManager? = null + } + + fun setup() { + configureWebViewSettings() + setupCookieManager() + setupWebViewClient() + setupJavaScriptInterfaces() + WebViewManager.shared = this // 👈 make this instance globally accessible + } + + private fun configureWebViewSettings() { + // Don't clear cache on every setup - let it persist for performance + // webView.clearCache(true) + // webView.clearHistory() + + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + loadsImagesAutomatically = true + blockNetworkImage = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + mediaPlaybackRequiresUserGesture = false // Allows autoplay + setSupportMultipleWindows(true) // Required for fullscreen + cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK // Prefer cache for faster loads + } + + WebView.setWebContentsDebuggingEnabled(true) + } + + private fun setupCookieManager() { + CookieManager.getInstance().apply { + setAcceptCookie(true) + setAcceptThirdPartyCookies(webView, true) + } + } + + private fun setupWebViewClient() { + webView.webChromeClient = createWebChromeClient() + webView.webViewClient = createCustomWebViewClient() + } + + private fun createWebChromeClient(): WebChromeClient { + return object : WebChromeClient() { + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + fullscreenView?.let { onHideCustomView() } + + fullscreenView = view + customViewCallback = callback + + (context as? Activity)?.let { activity -> + val decorView = activity.window.decorView as FrameLayout + decorView.addView(view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + webView.visibility = View.GONE + + (context as? Activity)?.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + override fun onHideCustomView() { + (context as? Activity)?.let { activity -> + val decorView = activity.window.decorView as FrameLayout + + fullscreenView?.let { decorView.removeView(it) } + fullscreenView = null + + webView.visibility = View.VISIBLE + + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + + customViewCallback?.onCustomViewHidden() + customViewCallback = null + } + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + Log.d( + "$TAG-Console", + "${consoleMessage.message()} -- From line ${consoleMessage.lineNumber()}" + ) + return true + } + } + } + + private fun createCustomWebViewClient(): WebViewClient { + return object : WebViewClient() { + private val requestInspector = RequestInspectorWebViewClient(webView) + private val phpHandler = PHPWebViewClient(phpBridge, context as MainActivity) + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val url = request.url.toString() + val method = request.method + Log.d("$TAG-DEBUG", "URL: $url, Method: $method") + Log.d(TAG, "⬆️ shouldOverrideUrlLoading: $url") + + // Handle system URL schemes (tel:, mailto:, sms:, geo:) - open with system handler + val scheme = request.url.scheme?.lowercase() + if (scheme in listOf("tel", "mailto", "sms", "geo")) { + Log.d("WebView", "📞 Intercepted system URL scheme: $url") + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("WebView", "No app can handle $scheme: links") + Toast.makeText(context, "No app can handle this link", Toast.LENGTH_SHORT).show() + } + return true + } + + if (url.startsWith("nativephp://")) { + Log.d("WebView", "🔗 Intercepted deep link inside WebView: $url") + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, "No app can handle this link", Toast.LENGTH_SHORT).show() + } + + return true // prevent WebView from loading it + } + + if ((url.startsWith("http://") || url.startsWith("https://")) && + !url.contains("127.0.0.1") && + !url.contains("localhost") && + request.isForMainFrame + ) { + // This is a navigation request to an external site - open in browser + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + view.context.startActivity(intent) + return true + } + + // Handle relative URLs (convert to php://) + if (url.startsWith("/")) { + val uri = request.url + val fullUrl = "http://127.0.0.1${uri.encodedPath}" + + (uri.encodedQuery?.let { "?$it" } ?: "") + + Log.d(TAG, "🛠️ Rewriting relative URL with query: $fullUrl") + view.loadUrl(fullUrl) + return true + } + + return false + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + val method = request.method + + Log.d(TAG, "🔄 Intercepting $method request to $url") + + request.requestHeaders.forEach { (key, value) -> + Log.d("$TAG-Headers", "📋 $key: $value") + } + + val inspectorResponse = requestInspector.shouldInterceptRequest(view, request) + + if (url.startsWith("http://") && !url.contains(".") && !url.contains("127.0.0.1") && !url.contains("localhost")) { + val host = url.substring("http://".length).substringBefore("/") + val path = if (url.contains("/")) "/${url.substringAfter("/")}" else "/" + val correctedUrl = "http://127.0.0.1/$host$path" + + Log.d(TAG, "🔄 Correcting malformed URL from $url to $correctedUrl") + + // Create a modified request with the corrected URL + val correctedUri = Uri.parse(correctedUrl) + val correctedRequest = object : WebResourceRequest { + override fun getUrl(): Uri = correctedUri + override fun isForMainFrame(): Boolean = request.isForMainFrame + override fun isRedirect(): Boolean = request.isRedirect + override fun hasGesture(): Boolean = request.hasGesture() + override fun getMethod(): String = request.method + override fun getRequestHeaders(): Map = request.requestHeaders + } + + // Handle this corrected request normally + return shouldInterceptRequest(view, correctedRequest) + } + + if (!url.contains("127.0.0.1") && !url.contains("localhost")) { + // This is an external resource - let the WebView handle it directly + Log.d(TAG, "📡 External resource - passing to system: $url") + return null // Returning null lets the WebView load it normally + } + + // Allow Vite dev server (port 5173) to handle its own requests, including WebSocket upgrades for HMR + if (url.contains(":5173")) { + Log.d(TAG, "🔥 Vite dev server request - allowing native WebView handling: $url") + return null + } + + return when { + isStaticAssetExtension(url) || + url.contains("_assets") || + url.contains("/js/") || + url.contains("/css/") || + url.contains("/fonts/") || + url.contains("/images/") -> { + Log.d(TAG, "🖼️ Handling asset request") + phpHandler.handleAssetRequest(url, request.requestHeaders) + } + // Regular PHP requests + url.contains("127.0.0.1") -> { + Log.d(TAG, "🌐 Handling PHP request") + val postData = if (request.method.equals("POST", ignoreCase = true) || + request.method.equals("PUT", ignoreCase = true) || + request.method.equals("PATCH", ignoreCase = true)) { + val reqId = request.requestHeaders?.get("X-NativePHP-Req-Id") + if (reqId != null) { + phpBridge.consumePostData(reqId) + } else { + // Native form submission — try full URL first, then path only + var data = phpBridge.consumePostData(url) + if (data == null) { + val path = request.url.path ?: "/" + data = phpBridge.consumePostData(path) + } + data + } + } else null + phpHandler.handlePHPRequest(request, postData) + } + else -> { + Log.d(TAG, "↪️ Delegating to system handler: $url") + inspectorResponse + } + } + } + + override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) { + super.onPageStarted(view, url, favicon) + Log.d(TAG, "🚀 Page started loading: $url") + + // Inject safe area insets IMMEDIATELY when page starts loading + // This ensures CSS variables are available before DOM parsing + (context as? MainActivity)?.injectSafeAreaInsetsToWebView() + } + + /** + * Process response headers - for HTML and JSON responses to handle native UI updates + * from both page loads and AJAX requests + */ + private fun processResponseHeaders( + url: String, + response: WebResourceResponse?, + request: WebResourceRequest + ) { + if (response == null) { + return + } + + val isMainFrame = request.isForMainFrame + + // Get content type + val contentType = response.responseHeaders?.entries?.firstOrNull { + it.key.equals("content-type", ignoreCase = true) + }?.value ?: "" + + val isHtmlResponse = contentType.contains("text/html", ignoreCase = true) + val isJsonResponse = contentType.contains("application/json", ignoreCase = true) + + // Find x-native-ui header (case-insensitive) + val nativeUiHeader = response.responseHeaders?.entries?.firstOrNull { + it.key.equals("x-native-ui", ignoreCase = true) + }?.value + + // Process for HTML pages (main frame) or JSON responses (AJAX) + if (isHtmlResponse || isJsonResponse) { + if (nativeUiHeader != null) { + Log.d(TAG, "✅ x-native-ui header found (${if (isJsonResponse) "JSON" else "HTML"}): $nativeUiHeader") + NativeUIState.updateFromJson(nativeUiHeader) + } else if (isHtmlResponse && isMainFrame) { + // Only clear UI state if this is a main frame HTML response without the header + // Don't clear for JSON responses to avoid clearing UI on every API call + Log.d(TAG, "❌ x-native-ui header NOT in HTML main frame - clearing state") + NativeUIState.clearAll() + } + } else { + // Asset request - ignore completely to avoid false negatives + Log.d(TAG, "⏭️ Skipping x-native-ui check for asset: $url") + } + } + + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + Log.d(TAG, "✅ Page finished loading: $url") + + // Inject safe area insets again to ensure they're set + (context as? MainActivity)?.injectSafeAreaInsetsToWebView() + + // Inject JavaScript to capture form submissions and AJAX requests + injectJavaScript(view) + } + } + } + + + private fun injectJavaScript(view: WebView) { + val jsCode = """ + (function() { + // 🌐 Native event bridge + const listeners = {}; + + const Native = { + on: function(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + off: function(eventName, callback) { + if (listeners[eventName]) { + listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); + } + }, + dispatch: function(eventName, payload) { + const cbs = listeners[eventName] || []; + cbs.forEach(cb => cb(payload, eventName)); + } + }; + + window.Native = Native; + + document.addEventListener("native-event", function (e) { + const eventName = e.detail.event; + const payload = e.detail.payload; + + window.Native.dispatch(eventName, payload); + + + }); + + // Unique request ID counter + var _nphpReqId = 0; + + // Capture form submissions — native form POSTs can't carry custom headers, + // so we store by URL for the fallback lookup in shouldInterceptRequest + document.addEventListener('submit', function(e) { + var form = e.target; + var method = form.method.toLowerCase(); + if (["post", "patch", "put"].includes(method)) { + var formData = new FormData(form); + var urlEncodedData = new URLSearchParams(); + for (var pair of formData.entries()) { + urlEncodedData.append(pair[0], pair[1]); + } + + var bodyStr = urlEncodedData.toString(); + // Store by URL — native form submissions don't support custom headers + AndroidPOST.logFormPostData(bodyStr, form.action); + } + }); + + // Capture XHR/AJAX requests + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + var originalXHRSetHeader = XMLHttpRequest.prototype.setRequestHeader; + + XMLHttpRequest.prototype.open = function(method, url) { + this._method = method; + this._url = url; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function(data) { + if (["post", "patch", "put"].includes(this._method.toLowerCase()) && data) { + var reqId = 'nphp_' + (++_nphpReqId) + '_' + Date.now(); + AndroidPOST.logPostData(String(data), this._url, "", reqId); + originalXHRSetHeader.call(this, 'X-NativePHP-Req-Id', reqId); + } + return originalXHRSend.apply(this, arguments); + }; + + // Capture fetch() requests + var originalFetch = window.fetch; + + window.fetch = function(url, options) { + if (options && options.method && ["post", "patch", "put"].includes(options.method.toLowerCase()) && options.body) { + var reqId = 'nphp_' + (++_nphpReqId) + '_' + Date.now(); + + var bodyStr = options.body; + if (options.body instanceof FormData) { + // Convert FormData to URLSearchParams for PHP form parsing + var urlParams = new URLSearchParams(); + options.body.forEach(function(value, key) { + urlParams.append(key, value); + }); + bodyStr = urlParams.toString(); + } else if (typeof options.body === 'object' && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) { + bodyStr = JSON.stringify(options.body); + } + + AndroidPOST.logPostData(String(bodyStr), url, "", reqId); + + // Add request ID header to the actual fetch request + if (!options.headers) { + options.headers = {}; + } + if (options.headers instanceof Headers) { + options.headers.set('X-NativePHP-Req-Id', reqId); + } else { + options.headers['X-NativePHP-Req-Id'] = reqId; + } + } + return originalFetch.apply(this, arguments); + }; + + // Find CSRF token + function findAndSendCsrfToken() { + var tokenField = document.querySelector('input[name="_token"]'); + if (tokenField) { + AndroidPOST.storeCsrfToken(tokenField.value); + return; + } + + if (window.livewire && window.livewire.csrfToken) { + AndroidPOST.storeCsrfToken(window.livewire.csrfToken); + } + } + + findAndSendCsrfToken(); + + var observer = new MutationObserver(function() { + findAndSendCsrfToken(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + return "POST+PATCH+PUT interception installed"; + })(); + """.trimIndent() + + view.evaluateJavascript(jsCode) { result -> + Log.d(TAG, "JavaScript injection result: $result") + } + } + + + private fun setupJavaScriptInterfaces() { + webView.addJavascriptInterface(JSBridge(phpBridge, TAG), "AndroidPOST") + webView.addJavascriptInterface(ttsBridge, "AndroidTTS") + Log.d(TAG, "[TTS] AndroidTTS bridge registered on WebView") + } + + fun shutdown() { + ttsBridge.shutdown() + } + + // Helper methods + fun isStaticAssetExtension(url: String): Boolean { + val staticExtensions = listOf( + ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".woff", + ".woff2", ".ttf", ".eot", ".ico", ".json", ".map" + ) + return staticExtensions.any { url.endsWith(it) || url.contains("$it?") } + } +} + +class JSBridge(private val phpBridge: PHPBridge, private val TAG: String) { + @JavascriptInterface + fun logPostData(data: String, url: String, headers: String, requestId: String) { + Log.d("$TAG-JS", "📦 POST data captured (fetch/XHR) for: $url reqId=$requestId (length=${data.length})") + + // Store by unique request ID — fetch/XHR requests carry the ID as a header + phpBridge.storePostData(requestId, data) + + // Try to extract CSRF token + LaravelSecurity.extractFromPostBody(data) + } + + @JavascriptInterface + fun logFormPostData(data: String, url: String) { + // Native form submissions can't carry custom headers, so store by URL + // shouldInterceptRequest will look up by URL in the fallback path + val path = android.net.Uri.parse(url).path ?: url + Log.d("$TAG-JS", "📦 POST data captured (form) for: $url path=$path (length=${data.length})") + + phpBridge.storePostData(url, data) + // Also store by path in case shouldInterceptRequest receives the full URL + if (path != url) { + phpBridge.storePostData(path, data) + } + + // Try to extract CSRF token + LaravelSecurity.extractFromPostBody(data) + } + + @JavascriptInterface + fun storeCsrfToken(token: String) { + Log.d("$TAG-CSRF", "🔑 JS provided token: $token") + LaravelSecurity.set(token) + } + + private fun extractCsrfToken(postData: String?) { + if (postData.isNullOrEmpty()) return + + try { + // Check if it's JSON + if (postData.startsWith("{")) { + val jsonObj = JSONObject(postData) + + // Look for _token field + if (jsonObj.has("_token")) { + val token = jsonObj.getString("_token") + Log.d("$TAG-CSRF", "🔑 Extracted token from POST data: $token") + LaravelSecurity.set(token) + } + } + // Check for form data format + else if (postData.contains("_token=")) { + val parts = postData.split("&") + for (part in parts) { + if (part.startsWith("_token=")) { + val token = part.substring("_token=".length) + Log.d("$TAG-CSRF", "🔑 Extracted token from form data: $token") + LaravelSecurity.set(token) + break + } + } + } + } catch (e: Exception) { + Log.e("$TAG-CSRF", "⚠️ Error extracting CSRF token: ${e.message}") + } + } +} diff --git a/version.json b/version.json index 588b684..44796bc 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.0.1", - "version_code": 2 + "version": "1.0.3", + "version_code": 4 } diff --git a/vite.config.js b/vite.config.js index f35b4e7..f060356 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,14 +1,17 @@ -import { defineConfig } from 'vite'; +import {defineConfig} from 'vite'; import laravel from 'laravel-vite-plugin'; import tailwindcss from '@tailwindcss/vite'; +import {nativephpHotFile, nativephpMobile} from './vendor/nativephp/mobile/resources/js/vite-plugin.js'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, + hotFile: nativephpHotFile(), }), tailwindcss(), + nativephpMobile(), ], server: { watch: {