diff --git a/pomodoro_app/assets/sounds/long_break_done.wav b/pomodoro_app/assets/sounds/long_break_done.wav new file mode 100644 index 0000000..0ff28b4 Binary files /dev/null and b/pomodoro_app/assets/sounds/long_break_done.wav differ diff --git a/pomodoro_app/assets/sounds/long_break_start.wav b/pomodoro_app/assets/sounds/long_break_start.wav new file mode 100644 index 0000000..9b5fba1 Binary files /dev/null and b/pomodoro_app/assets/sounds/long_break_start.wav differ diff --git a/pomodoro_app/assets/sounds/short_break_done.wav b/pomodoro_app/assets/sounds/short_break_done.wav new file mode 100644 index 0000000..cce2f11 Binary files /dev/null and b/pomodoro_app/assets/sounds/short_break_done.wav differ diff --git a/pomodoro_app/assets/sounds/work_done.wav b/pomodoro_app/assets/sounds/work_done.wav new file mode 100644 index 0000000..972c71b Binary files /dev/null and b/pomodoro_app/assets/sounds/work_done.wav differ diff --git a/pomodoro_app/linux/flutter/generated_plugin_registrant.cc b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc index e71a16d..1830e5c 100644 --- a/pomodoro_app/linux/flutter/generated_plugin_registrant.cc +++ b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); } diff --git a/pomodoro_app/linux/flutter/generated_plugins.cmake b/pomodoro_app/linux/flutter/generated_plugins.cmake index 2e1de87..e9abb91 100644 --- a/pomodoro_app/linux/flutter/generated_plugins.cmake +++ b/pomodoro_app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pomodoro_app/pubspec.lock b/pomodoro_app/pubspec.lock index 764bc87..51e620c 100644 --- a/pomodoro_app/pubspec.lock +++ b/pomodoro_app/pubspec.lock @@ -9,6 +9,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -33,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +129,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -75,6 +171,43 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -107,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +272,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -139,6 +296,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -176,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -192,6 +429,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: @@ -208,6 +461,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pomodoro_app/pubspec.yaml b/pomodoro_app/pubspec.yaml index e1389cc..7610d6c 100644 --- a/pomodoro_app/pubspec.yaml +++ b/pomodoro_app/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + audioplayers: ^6.1.0 dev_dependencies: flutter_test: @@ -56,10 +57,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/sounds/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/pomodoro_app/test/services/sound_service_test.dart b/pomodoro_app/test/services/sound_service_test.dart new file mode 100644 index 0000000..840fb7e --- /dev/null +++ b/pomodoro_app/test/services/sound_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pomodoro_app/models/pomodoro_state.dart'; +import 'package:pomodoro_app/services/sound_service.dart'; + +void main() { + group('SoundService', () { + late List playedAssets; + late SoundService service; + + setUp(() { + playedAssets = []; + service = SoundService( + playCallback: (assetPath) async => playedAssets.add(assetPath), + ); + }); + + tearDown(() { + service.dispose(); + }); + + test('plays work_done when work ends with short break next', () async { + await service.playTransitionSound( + completedMode: PomodoroMode.work, + nextMode: PomodoroMode.shortBreak, + ); + expect(playedAssets, ['work_done.wav']); + }); + + test('plays long_break_start when work ends with long break next', + () async { + await service.playTransitionSound( + completedMode: PomodoroMode.work, + nextMode: PomodoroMode.longBreak, + ); + expect(playedAssets, ['long_break_start.wav']); + }); + + test('plays short_break_done when short break ends', () async { + await service.playTransitionSound( + completedMode: PomodoroMode.shortBreak, + nextMode: PomodoroMode.work, + ); + expect(playedAssets, ['short_break_done.wav']); + }); + + test('plays long_break_done when long break ends', () async { + await service.playTransitionSound( + completedMode: PomodoroMode.longBreak, + nextMode: PomodoroMode.work, + ); + expect(playedAssets, ['long_break_done.wav']); + }); + + test('does nothing after dispose', () async { + service.dispose(); + await service.playTransitionSound( + completedMode: PomodoroMode.work, + nextMode: PomodoroMode.shortBreak, + ); + expect(playedAssets, isEmpty); + }); + }); +} diff --git a/pomodoro_app/tools/__init__.py b/pomodoro_app/tools/__init__.py new file mode 100644 index 0000000..9fae973 --- /dev/null +++ b/pomodoro_app/tools/__init__.py @@ -0,0 +1 @@ +"""Pomodoro app development tools.""" diff --git a/pomodoro_app/tools/generate_sounds.py b/pomodoro_app/tools/generate_sounds.py new file mode 100644 index 0000000..35796e2 --- /dev/null +++ b/pomodoro_app/tools/generate_sounds.py @@ -0,0 +1,101 @@ +"""Generate distinct notification sounds for the Pomodoro app.""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path +import struct +import wave + +logger = logging.getLogger(__name__) + +SAMPLE_RATE = 44100 + + +def _write_wav(path: Path, samples: list[int]) -> None: + with wave.open(str(path), "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(SAMPLE_RATE) + w.writeframes(struct.pack(f"<{len(samples)}h", *samples)) + + +def _tone(freq: float, duration: float, volume: float = 0.7) -> list[int]: + n = int(SAMPLE_RATE * duration) + return [ + int(volume * 32767 * math.sin(2 * math.pi * freq * i / SAMPLE_RATE)) + for i in range(n) + ] + + +def _fade(samples: list[int], fade_ms: int = 20) -> list[int]: + n = int(SAMPLE_RATE * fade_ms / 1000) + out = list(samples) + for i in range(min(n, len(out))): + out[i] = int(out[i] * i / n) + for i in range(min(n, len(out))): + out[-(i + 1)] = int(out[-(i + 1)] * i / n) + return out + + +def _silence(duration: float) -> list[int]: + return [0] * int(SAMPLE_RATE * duration) + + +def work_done(out: Path) -> None: + """End of pomodoro: upward three-note chime (C5-E5-G5).""" + samples = ( + _fade(_tone(523.25, 0.2)) + + _silence(0.05) + + _fade(_tone(659.25, 0.2)) + + _silence(0.05) + + _fade(_tone(783.99, 0.4)) + ) + _write_wav(out, samples) + + +def short_break_done(out: Path) -> None: + """End of short break: two gentle pings (G5-C6).""" + samples = _fade(_tone(783.99, 0.15)) + _silence(0.08) + _fade(_tone(1046.50, 0.3)) + _write_wav(out, samples) + + +def long_break_start(out: Path) -> None: + """Start of long break: descending celebration (G5-E5-C5-C4 long).""" + samples = ( + _fade(_tone(783.99, 0.15)) + + _silence(0.04) + + _fade(_tone(659.25, 0.15)) + + _silence(0.04) + + _fade(_tone(523.25, 0.15)) + + _silence(0.04) + + _fade(_tone(261.63, 0.6, volume=0.5)) + ) + _write_wav(out, samples) + + +def long_break_done(out: Path) -> None: + """End of long break: wake-up alarm — rapid repeated beeps.""" + beep = _fade(_tone(880.0, 0.1)) + gap = _silence(0.08) + samples = (beep + gap) * 4 + _fade(_tone(1046.50, 0.3)) + _write_wav(out, samples) + + +def main() -> None: + """Generate all notification sounds and log file sizes.""" + out_dir = Path(__file__).resolve().parent.parent / "assets" / "sounds" + out_dir.mkdir(parents=True, exist_ok=True) + + work_done(out_dir / "work_done.wav") + short_break_done(out_dir / "short_break_done.wav") + long_break_start(out_dir / "long_break_start.wav") + long_break_done(out_dir / "long_break_done.wav") + + for f in sorted(out_dir.glob("*.wav")): + logger.info(" %s (%d bytes)", f.name, f.stat().st_size) + + +if __name__ == "__main__": + main()