diff --git a/pomodoro_app/.gitignore b/pomodoro_app/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/pomodoro_app/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/pomodoro_app/.metadata b/pomodoro_app/.metadata
new file mode 100644
index 0000000..2ef18ea
--- /dev/null
+++ b/pomodoro_app/.metadata
@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ - platform: android
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ - platform: linux
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/pomodoro_app/README.md b/pomodoro_app/README.md
new file mode 100644
index 0000000..9511899
--- /dev/null
+++ b/pomodoro_app/README.md
@@ -0,0 +1,17 @@
+# pomodoro_app
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/pomodoro_app/analysis_options.yaml b/pomodoro_app/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/pomodoro_app/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/pomodoro_app/android/.gitignore b/pomodoro_app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/pomodoro_app/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/pomodoro_app/android/app/build.gradle.kts b/pomodoro_app/android/app/build.gradle.kts
new file mode 100644
index 0000000..c47e490
--- /dev/null
+++ b/pomodoro_app/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.kuhy.pomodoro_app"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.kuhy.pomodoro_app"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/pomodoro_app/android/app/src/debug/AndroidManifest.xml b/pomodoro_app/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/pomodoro_app/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/AndroidManifest.xml b/pomodoro_app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..05764e4
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt b/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt
new file mode 100644
index 0000000..fb4a8a4
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt
@@ -0,0 +1,43 @@
+package com.kuhy.pomodoro_app
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+
+class MainActivity : FlutterActivity() {
+ private var multicastLock: WifiManager.MulticastLock? = null
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ "pomodoro_multicast_lock"
+ ).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "acquire" -> {
+ val wifi = applicationContext
+ .getSystemService(Context.WIFI_SERVICE) as WifiManager
+ multicastLock = wifi.createMulticastLock("pomodoro_sync")
+ multicastLock?.setReferenceCounted(true)
+ multicastLock?.acquire()
+ result.success(true)
+ }
+ "release" -> {
+ multicastLock?.release()
+ multicastLock = null
+ result.success(true)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ multicastLock?.release()
+ multicastLock = null
+ super.onDestroy()
+ }
+}
diff --git a/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml b/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml b/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/values-night/styles.xml b/pomodoro_app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/values/styles.xml b/pomodoro_app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/profile/AndroidManifest.xml b/pomodoro_app/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/pomodoro_app/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/pomodoro_app/android/build.gradle.kts b/pomodoro_app/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/pomodoro_app/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/pomodoro_app/android/gradle.properties b/pomodoro_app/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/pomodoro_app/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties b/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/pomodoro_app/android/settings.gradle.kts b/pomodoro_app/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /dev/null
+++ b/pomodoro_app/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/pomodoro_app/linux/.gitignore b/pomodoro_app/linux/.gitignore
new file mode 100644
index 0000000..d3896c9
--- /dev/null
+++ b/pomodoro_app/linux/.gitignore
@@ -0,0 +1 @@
+flutter/ephemeral
diff --git a/pomodoro_app/linux/CMakeLists.txt b/pomodoro_app/linux/CMakeLists.txt
new file mode 100644
index 0000000..7b63cc8
--- /dev/null
+++ b/pomodoro_app/linux/CMakeLists.txt
@@ -0,0 +1,128 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "pomodoro_app")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.kuhy.pomodoro_app")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/pomodoro_app/linux/flutter/CMakeLists.txt b/pomodoro_app/linux/flutter/CMakeLists.txt
new file mode 100644
index 0000000..d5bd016
--- /dev/null
+++ b/pomodoro_app/linux/flutter/CMakeLists.txt
@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/pomodoro_app/linux/flutter/generated_plugin_registrant.cc b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..e71a16d
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,11 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+}
diff --git a/pomodoro_app/linux/flutter/generated_plugin_registrant.h b/pomodoro_app/linux/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..e0f0a47
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/pomodoro_app/linux/flutter/generated_plugins.cmake b/pomodoro_app/linux/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..2e1de87
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugins.cmake
@@ -0,0 +1,23 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/pomodoro_app/linux/runner/CMakeLists.txt b/pomodoro_app/linux/runner/CMakeLists.txt
new file mode 100644
index 0000000..e97dabc
--- /dev/null
+++ b/pomodoro_app/linux/runner/CMakeLists.txt
@@ -0,0 +1,26 @@
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the application ID.
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
diff --git a/pomodoro_app/linux/runner/main.cc b/pomodoro_app/linux/runner/main.cc
new file mode 100644
index 0000000..e7c5c54
--- /dev/null
+++ b/pomodoro_app/linux/runner/main.cc
@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/pomodoro_app/linux/runner/my_application.cc b/pomodoro_app/linux/runner/my_application.cc
new file mode 100644
index 0000000..c9ab02e
--- /dev/null
+++ b/pomodoro_app/linux/runner/my_application.cc
@@ -0,0 +1,148 @@
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Called when first Flutter frame received.
+static void first_frame_cb(MyApplication* self, FlView* view) {
+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
+}
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
+ gtk_header_bar_set_title(header_bar, "pomodoro_app");
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
+ gtk_window_set_title(window, "pomodoro_app");
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(
+ project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ GdkRGBA background_color;
+ // Background defaults to black, override it here if necessary, e.g. #00000000
+ // for transparent.
+ gdk_rgba_parse(&background_color, "#000000");
+ fl_view_set_background_color(view, &background_color);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ // Show the window when Flutter renders.
+ // Requires the view to be realized so we can start rendering.
+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
+ self);
+ gtk_widget_realize(GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application,
+ gchar*** arguments,
+ int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line =
+ my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ // Set the program name to the application ID, which helps various systems
+ // like GTK and desktop environments map this running application to its
+ // corresponding .desktop file. This ensures better integration by allowing
+ // the application to be recognized beyond its binary name.
+ g_set_prgname(APPLICATION_ID);
+
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID, "flags",
+ G_APPLICATION_NON_UNIQUE, nullptr));
+}
diff --git a/pomodoro_app/linux/runner/my_application.h b/pomodoro_app/linux/runner/my_application.h
new file mode 100644
index 0000000..db16367
--- /dev/null
+++ b/pomodoro_app/linux/runner/my_application.h
@@ -0,0 +1,21 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication,
+ my_application,
+ MY,
+ APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/pomodoro_app/packaging/arch/.gitignore b/pomodoro_app/packaging/arch/.gitignore
new file mode 100644
index 0000000..3a09c6e
--- /dev/null
+++ b/pomodoro_app/packaging/arch/.gitignore
@@ -0,0 +1,2 @@
+pkg/
+*.pkg.tar.zst
diff --git a/pomodoro_app/packaging/arch/PKGBUILD b/pomodoro_app/packaging/arch/PKGBUILD
new file mode 100644
index 0000000..80384af
--- /dev/null
+++ b/pomodoro_app/packaging/arch/PKGBUILD
@@ -0,0 +1,76 @@
+# shellcheck shell=bash
+# shellcheck disable=SC2034,SC2154
+# SC2034: Variables like pkgver, pkgrel etc. are used by makepkg.
+# SC2154: Variables like startdir, pkgdir are provided by makepkg.
+# Maintainer: kuhy
+pkgname=pomodoro-app
+pkgver=1.0.0
+pkgrel=1
+pkgdesc='A Pomodoro timer with LAN sync between devices'
+arch=('x86_64')
+url='https://github.com/kuhy/testsAndMisc'
+license=('MIT')
+depends=(
+ 'gtk3'
+ 'glib2'
+ 'python'
+ 'zlib'
+)
+makedepends=(
+ 'clang'
+ 'cmake'
+ 'ninja'
+ 'pkg-config'
+)
+
+# Flutter must be available on PATH (e.g. via fvm or manual install).
+# Install: https://docs.flutter.dev/get-started/install/linux/desktop
+
+build() {
+ cd "$startdir/../.." || return
+
+ if ! command -v flutter >/dev/null 2>&1; then
+ # Try common fvm location.
+ export PATH="$HOME/fvm/default/bin:$PATH"
+ fi
+
+ flutter build linux --release
+}
+
+package() {
+ cd "$startdir/../.." || return
+
+ local _bundle="build/linux/x64/release/bundle"
+
+ # Install the main binary.
+ install -Dm755 "$_bundle/pomodoro_app" \
+ "$pkgdir/usr/lib/$pkgname/pomodoro_app"
+
+ # Install bundled shared libraries.
+ install -Dm644 "$_bundle/lib/libapp.so" \
+ "$pkgdir/usr/lib/$pkgname/lib/libapp.so"
+ install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
+ "$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
+
+ # Install data directory.
+ install -Dm644 "$_bundle/data/icudtl.dat" \
+ "$pkgdir/usr/lib/$pkgname/data/icudtl.dat"
+ cp -r "$_bundle/data/flutter_assets" \
+ "$pkgdir/usr/lib/$pkgname/data/flutter_assets"
+
+ # Install launcher script.
+ install -Dm755 "packaging/arch/pomodoro-app.sh" \
+ "$pkgdir/usr/bin/pomodoro-app"
+
+ # Install desktop entry and icon.
+ install -Dm644 "packaging/arch/pomodoro-app.desktop" \
+ "$pkgdir/usr/share/applications/pomodoro-app.desktop"
+ install -Dm644 "packaging/arch/pomodoro-app.svg" \
+ "$pkgdir/usr/share/icons/hicolor/scalable/apps/pomodoro-app.svg"
+
+ # Install wake daemon and systemd user service.
+ install -Dm755 "packaging/arch/pomodoro-wake-daemon.py" \
+ "$pkgdir/usr/bin/pomodoro-wake-daemon"
+ install -Dm644 "packaging/arch/pomodoro-wake-daemon.service" \
+ "$pkgdir/usr/lib/systemd/user/pomodoro-wake-daemon.service"
+}
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.desktop b/pomodoro_app/packaging/arch/pomodoro-app.desktop
new file mode 100644
index 0000000..1891e23
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=Pomodoro Timer
+Comment=Pomodoro timer with LAN sync
+Exec=pomodoro-app
+Icon=pomodoro-app
+Terminal=false
+Categories=Utility;Clock;GTK;
+Keywords=pomodoro;timer;focus;productivity;
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.sh b/pomodoro_app/packaging/arch/pomodoro-app.sh
new file mode 100755
index 0000000..1ed2bef
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec /usr/lib/pomodoro-app/pomodoro_app "$@"
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.svg b/pomodoro_app/packaging/arch/pomodoro-app.svg
new file mode 100644
index 0000000..e75ebe3
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.svg
@@ -0,0 +1,18 @@
+
diff --git a/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py
new file mode 100755
index 0000000..8f26365
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Pomodoro wake daemon.
+
+Listens for UDP wake broadcasts from the Pomodoro app and automatically
+launches the app on:
+ - the local desktop (if not already running)
+ - connected Android devices via ADB (if available)
+
+Intended to run as a systemd user service so that opening the app on any
+device opens it everywhere.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+import socket
+import subprocess
+import time
+
+WAKE_PORT = 41235
+APP_PROCESS = "pomodoro_app"
+APP_COMMAND = "pomodoro-app"
+ANDROID_PACKAGE = "com.kuhy.pomodoro_app"
+ANDROID_ACTIVITY = ".MainActivity"
+
+# Minimum seconds between consecutive launches to avoid rapid re-triggers.
+LAUNCH_COOLDOWN = 5
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [pomodoro-wake] %(message)s",
+ datefmt="%H:%M:%S",
+)
+log = logging.getLogger(__name__)
+
+
+def is_app_running() -> bool:
+ """Check whether the Pomodoro app is running locally."""
+ pgrep = shutil.which("pgrep")
+ if pgrep is None:
+ return False
+ try:
+ result = subprocess.run(
+ [pgrep, "-f", APP_PROCESS],
+ capture_output=True,
+ check=False,
+ )
+ except FileNotFoundError:
+ return False
+ return result.returncode == 0
+
+
+def launch_local() -> None:
+ """Launch the Pomodoro app on the local desktop."""
+ if is_app_running():
+ log.info("Local app already running, skipping launch")
+ return
+ cmd = shutil.which(APP_COMMAND)
+ if cmd is None:
+ log.warning("%s not found on PATH", APP_COMMAND)
+ return
+ log.info("Launching local app: %s", cmd)
+ subprocess.Popen(
+ [cmd],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+
+
+def get_adb_devices() -> list[str]:
+ """Return list of connected ADB device serial numbers."""
+ adb = shutil.which("adb")
+ if adb is None:
+ return []
+ try:
+ result = subprocess.run(
+ [adb, "devices"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+ devices: list[str] = []
+ for line in result.stdout.strip().splitlines()[1:]:
+ parts = line.split()
+ if len(parts) >= 2 and parts[1] == "device": # noqa: PLR2004
+ devices.append(parts[0])
+ return devices
+
+
+def _launch_on_device(adb: str, serial: str, component: str) -> None:
+ """Launch the Pomodoro app on a single Android device."""
+ log.info("Launching on Android device %s", serial)
+ cmd = [adb, "-s", serial, "shell", "am", "start", "-n", component]
+ try:
+ subprocess.run(cmd, capture_output=True, timeout=10, check=False)
+ except subprocess.TimeoutExpired:
+ log.warning("Timeout launching on %s", serial)
+
+
+def launch_android(devices: list[str]) -> None:
+ """Launch the Pomodoro app on connected Android devices."""
+ adb = shutil.which("adb")
+ if adb is None:
+ return
+ component = f"{ANDROID_PACKAGE}/{ANDROID_ACTIVITY}"
+ for serial in devices:
+ _launch_on_device(adb, serial, component)
+
+
+def _handle_wake(sock: socket.socket, last_launch: float) -> float:
+ """Handle a single wake signal. Returns updated last_launch time."""
+ try:
+ data, addr = sock.recvfrom(4096)
+ except OSError:
+ return last_launch
+ try:
+ msg = json.loads(data)
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return last_launch
+ if msg.get("action") != "wake":
+ return last_launch
+ device_id = msg.get("deviceId", "unknown")
+ log.info("Received wake from %s (%s)", device_id, addr[0])
+ now = time.monotonic()
+ if now - last_launch < LAUNCH_COOLDOWN:
+ log.info("Cooldown active, skipping launch")
+ return last_launch
+ launch_local()
+ launch_android(get_adb_devices())
+ return now
+
+
+def main() -> None:
+ """Run the wake daemon loop."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind(("", WAKE_PORT))
+ log.info("Listening for wake signals on UDP port %d", WAKE_PORT)
+ last_launch = 0.0
+ while True:
+ last_launch = _handle_wake(sock, last_launch)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service
new file mode 100644
index 0000000..d59b200
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Pomodoro wake daemon - auto-launches app across devices
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/pomodoro-wake-daemon
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
diff --git a/pomodoro_app/pubspec.lock b/pomodoro_app/pubspec.lock
new file mode 100644
index 0000000..764bc87
--- /dev/null
+++ b/pomodoro_app/pubspec.lock
@@ -0,0 +1,213 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ cupertino_icons:
+ dependency: "direct main"
+ description:
+ name: cupertino_icons
+ sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.8"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.3"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.18"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.0"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.17.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.2"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.9"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.0.2"
+sdks:
+ dart: ">=3.11.0 <4.0.0"
+ flutter: ">=3.18.0-18.0.pre.54"
diff --git a/pomodoro_app/pubspec.yaml b/pomodoro_app/pubspec.yaml
new file mode 100644
index 0000000..e1389cc
--- /dev/null
+++ b/pomodoro_app/pubspec.yaml
@@ -0,0 +1,88 @@
+name: pomodoro_app
+description: "A new Flutter project."
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: "none" # Remove this line if you wish to publish to pub.dev
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
+# Read more about iOS versioning at
+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
+# In Windows, build-name is used as the major, minor, and patch parts
+# of the product and file versions while build-number is used as the build suffix.
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.11.0
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # The following adds the Cupertino Icons font to your application.
+ # Use with the CupertinoIcons class for iOS style icons.
+ cupertino_icons: ^1.0.8
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ # The "flutter_lints" package below contains a set of recommended lints to
+ # encourage good coding practices. The lint set provided by the package is
+ # activated in the `analysis_options.yaml` file located at the root of your
+ # package. See that file for information about deactivating specific lint
+ # rules and activating additional ones.
+ flutter_lints: ^6.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # 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
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/to/resolution-aware-images
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/to/asset-from-package
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/to/font-from-package
diff --git a/pomodoro_app/test/models/pomodoro_state_test.dart b/pomodoro_app/test/models/pomodoro_state_test.dart
new file mode 100644
index 0000000..a9ebd92
--- /dev/null
+++ b/pomodoro_app/test/models/pomodoro_state_test.dart
@@ -0,0 +1,129 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+
+void main() {
+ group('PomodoroMode', () {
+ test('label returns correct strings', () {
+ expect(PomodoroMode.work.label, 'Work');
+ expect(PomodoroMode.shortBreak.label, 'Short Break');
+ expect(PomodoroMode.longBreak.label, 'Long Break');
+ });
+ });
+
+ group('PomodoroState.initial', () {
+ test('creates default state', () {
+ final state = PomodoroState.initial();
+ expect(state.mode, PomodoroMode.work);
+ expect(state.remainingSeconds, 25 * 60);
+ expect(state.totalSeconds, 25 * 60);
+ expect(state.isRunning, false);
+ expect(state.completedPomodoros, 0);
+ expect(state.pomodorosPerCycle, 4);
+ });
+
+ test('creates state with custom durations', () {
+ final state = PomodoroState.initial(
+ workMinutes: 30,
+ shortBreakMinutes: 10,
+ longBreakMinutes: 20,
+ pomodorosPerCycle: 3,
+ );
+ expect(state.remainingSeconds, 30 * 60);
+ expect(state.totalSeconds, 30 * 60);
+ expect(state.pomodorosPerCycle, 3);
+ });
+ });
+
+ group('PomodoroState.progress', () {
+ test('returns 0.0 at start', () {
+ final state = PomodoroState.initial();
+ expect(state.progress, 0.0);
+ });
+
+ test('returns 0.5 at halfway', () {
+ final state = PomodoroState.initial().copyWith(
+ remainingSeconds: 25 * 30, // half of 25*60
+ );
+ expect(state.progress, closeTo(0.5, 0.001));
+ });
+
+ test('returns 1.0 when totalSeconds is 0', () {
+ final state = PomodoroState.initial().copyWith(
+ totalSeconds: 0,
+ remainingSeconds: 0,
+ );
+ expect(state.progress, 1.0);
+ });
+
+ test('returns close to 1.0 at end', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 0);
+ expect(state.progress, 1.0);
+ });
+ });
+
+ group('PomodoroState.formattedTime', () {
+ test('formats full time correctly', () {
+ final state = PomodoroState.initial(); // 25:00
+ expect(state.formattedTime, '25:00');
+ });
+
+ test('formats single-digit minutes with padding', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 5 * 60 + 30);
+ expect(state.formattedTime, '05:30');
+ });
+
+ test('formats zero correctly', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 0);
+ expect(state.formattedTime, '00:00');
+ });
+
+ test('formats seconds with padding', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 60 + 5);
+ expect(state.formattedTime, '01:05');
+ });
+ });
+
+ group('PomodoroState.copyWith', () {
+ test('copies with mode change', () {
+ final original = PomodoroState.initial();
+ final copy = original.copyWith(mode: PomodoroMode.shortBreak);
+ expect(copy.mode, PomodoroMode.shortBreak);
+ expect(copy.remainingSeconds, original.remainingSeconds);
+ });
+
+ test('preserves values when no parameters given', () {
+ final original = PomodoroState.initial();
+ final copy = original.copyWith();
+ expect(copy, original);
+ });
+ });
+
+ group('PomodoroState equality', () {
+ test('equal states are ==', () {
+ final a = PomodoroState.initial();
+ final b = PomodoroState.initial();
+ expect(a, b);
+ expect(a.hashCode, b.hashCode);
+ });
+
+ test('different states are !=', () {
+ final a = PomodoroState.initial();
+ final b = a.copyWith(remainingSeconds: 100);
+ expect(a, isNot(b));
+ });
+
+ test('identical references are ==', () {
+ final a = PomodoroState.initial();
+ // ignore: prefer_const_declarations
+ final b = a;
+ expect(identical(a, b), true);
+ expect(a, b);
+ });
+
+ test('different type is !=', () {
+ final a = PomodoroState.initial();
+ // ignore: unrelated_type_equality_checks
+ expect(a == 'not a state', false);
+ });
+ });
+}
diff --git a/pomodoro_app/test/screens/pomodoro_screen_test.dart b/pomodoro_app/test/screens/pomodoro_screen_test.dart
new file mode 100644
index 0000000..d533b51
--- /dev/null
+++ b/pomodoro_app/test/screens/pomodoro_screen_test.dart
@@ -0,0 +1,178 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/screens/pomodoro_screen.dart';
+import 'package:pomodoro_app/services/pomodoro_timer.dart';
+
+/// Controllable fake timer for widget tests.
+class FakeTimerController {
+ void Function(Timer)? _callback;
+ bool _isActive = true;
+
+ void tick() {
+ if (_isActive) {
+ _callback?.call(_FakeTimer(this));
+ }
+ }
+
+ void cancel() {
+ _isActive = false;
+ }
+
+ bool get isActive => _isActive;
+}
+
+class _FakeTimer implements Timer {
+ _FakeTimer(this._controller);
+ final FakeTimerController _controller;
+
+ @override
+ void cancel() => _controller.cancel();
+
+ @override
+ bool get isActive => _controller.isActive;
+
+ @override
+ int get tick => 0;
+}
+
+void main() {
+ late PomodoroTimer timer;
+ late FakeTimerController fakeController;
+
+ Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
+ fakeController = FakeTimerController();
+ fakeController._callback = callback;
+ return _FakeTimer(fakeController);
+ }
+
+ setUp(() {
+ timer = PomodoroTimer(
+ workMinutes: 1,
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 4,
+ timerFactory: fakeTimerFactory,
+ );
+ });
+
+ tearDown(() {
+ timer.dispose();
+ });
+
+ Widget createApp() {
+ return MaterialApp(
+ home: PomodoroScreen(timer: timer),
+ );
+ }
+
+ group('PomodoroScreen', () {
+ testWidgets('shows initial time', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('01:00'), findsOneWidget);
+ });
+
+ testWidgets('shows Work label initially', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('Work'), findsOneWidget);
+ });
+
+ testWidgets('shows 0 pomodoros completed', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('0 pomodoros completed'), findsOneWidget);
+ });
+
+ testWidgets('play button starts timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Find and tap the play button.
+ final playButton = find.byIcon(Icons.play_arrow);
+ expect(playButton, findsOneWidget);
+ await tester.tap(playButton);
+ await tester.pump();
+
+ // After ticking, time should decrease.
+ fakeController.tick();
+ await tester.pump();
+ expect(find.text('00:59'), findsOneWidget);
+ });
+
+ testWidgets('pause button appears when running', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ expect(find.byIcon(Icons.pause), findsOneWidget);
+ });
+
+ testWidgets('pause button pauses timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ fakeController.tick();
+ await tester.pump();
+
+ // Pause.
+ await tester.tap(find.byIcon(Icons.pause));
+ await tester.pump();
+
+ expect(find.text('00:59'), findsOneWidget);
+ expect(find.byIcon(Icons.play_arrow), findsOneWidget);
+ });
+
+ testWidgets('reset button resets time', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start and tick.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+ fakeController.tick();
+ fakeController.tick();
+ await tester.pump();
+
+ // Reset.
+ await tester.tap(find.byIcon(Icons.refresh));
+ await tester.pump();
+
+ expect(find.text('01:00'), findsOneWidget);
+ });
+
+ testWidgets('skip button moves to next mode', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.byIcon(Icons.skip_next));
+ await tester.pump();
+
+ expect(find.text('Short Break'), findsOneWidget);
+ });
+
+ testWidgets('shows correct completed count after session', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start and complete a work session.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ await tester.pump();
+
+ expect(find.text('1 pomodoro completed'), findsOneWidget);
+ });
+
+ testWidgets('has 4 indicator dots', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // There should be 4 AnimatedContainers for indicators.
+ // We can check that the PomodoroIndicators widget is present.
+ expect(find.text('0 pomodoros completed'), findsOneWidget);
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/pomodoro_timer_test.dart b/pomodoro_app/test/services/pomodoro_timer_test.dart
new file mode 100644
index 0000000..e071bca
--- /dev/null
+++ b/pomodoro_app/test/services/pomodoro_timer_test.dart
@@ -0,0 +1,302 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/pomodoro_timer.dart';
+
+/// A controllable fake timer for testing.
+class FakeTimerController {
+ void Function(Timer)? _callback;
+ bool _isActive = true;
+
+ void tick() {
+ if (_isActive) {
+ _callback?.call(_FakeTimer(this));
+ }
+ }
+
+ void cancel() {
+ _isActive = false;
+ }
+
+ bool get isActive => _isActive;
+}
+
+class _FakeTimer implements Timer {
+ _FakeTimer(this._controller);
+ final FakeTimerController _controller;
+
+ @override
+ void cancel() => _controller.cancel();
+
+ @override
+ bool get isActive => _controller.isActive;
+
+ @override
+ int get tick => 0;
+}
+
+void main() {
+ late PomodoroTimer timer;
+ late FakeTimerController fakeController;
+
+ Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
+ fakeController = FakeTimerController();
+ fakeController._callback = callback;
+ return _FakeTimer(fakeController);
+ }
+
+ setUp(() {
+ timer = PomodoroTimer(
+ workMinutes: 1, // 60 seconds for faster testing
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 2,
+ timerFactory: fakeTimerFactory,
+ );
+ });
+
+ tearDown(() {
+ timer.dispose();
+ });
+
+ group('Initial state', () {
+ test('starts in work mode', () {
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+
+ test('is not running', () {
+ expect(timer.state.isRunning, false);
+ });
+
+ test('has correct initial time', () {
+ expect(timer.state.remainingSeconds, 60);
+ expect(timer.state.totalSeconds, 60);
+ });
+
+ test('has zero completed pomodoros', () {
+ expect(timer.state.completedPomodoros, 0);
+ });
+ });
+
+ group('start()', () {
+ test('sets isRunning to true', () {
+ timer.start();
+ expect(timer.state.isRunning, true);
+ });
+
+ test('does nothing if already running', () {
+ timer.start();
+ final stateAfterFirstStart = timer.state;
+ timer.start(); // second call
+ expect(timer.state, stateAfterFirstStart);
+ });
+
+ test('notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+ timer.start();
+ expect(notified, true);
+ });
+ });
+
+ group('pause()', () {
+ test('sets isRunning to false', () {
+ timer.start();
+ timer.pause();
+ expect(timer.state.isRunning, false);
+ });
+
+ test('does nothing if already paused', () {
+ final state = timer.state;
+ timer.pause();
+ expect(timer.state, state);
+ });
+
+ test('preserves remaining time', () {
+ timer.start();
+ fakeController.tick(); // -1s
+ fakeController.tick(); // -1s
+ timer.pause();
+ expect(timer.state.remainingSeconds, 58);
+ });
+ });
+
+ group('Ticking', () {
+ test('decrements remaining seconds', () {
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 59);
+ });
+
+ test('notifies on each tick', () {
+ timer.start();
+ var count = 0;
+ timer.addListener(() => count++);
+ fakeController.tick();
+ fakeController.tick();
+ expect(count, 2);
+ });
+ });
+
+ group('Session completion', () {
+ test('transitions from work to short break', () {
+ timer.start();
+ // Tick down to 1 second, then one more tick completes.
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.isRunning, false);
+ expect(timer.state.completedPomodoros, 1);
+ });
+
+ test('transitions to long break after cycle', () {
+ // Complete 2 pomodoros (pomodorosPerCycle = 2).
+ // First pomodoro.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+
+ // Skip break.
+ timer.skip();
+ expect(timer.state.mode, PomodoroMode.work);
+
+ // Second pomodoro.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.longBreak);
+ expect(timer.state.completedPomodoros, 2);
+ });
+
+ test('transitions from break to work', () {
+ // Complete a work session.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+
+ // Complete the break.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+ });
+
+ group('reset()', () {
+ test('resets to full duration', () {
+ timer.start();
+ fakeController.tick();
+ fakeController.tick();
+ timer.reset();
+ expect(timer.state.remainingSeconds, 60);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('keeps the current mode', () {
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ // Now in short break mode.
+ timer.reset();
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ });
+ });
+
+ group('skip()', () {
+ test('skips from work to short break', () {
+ timer.skip();
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('skips from break to work', () {
+ timer.skip(); // work -> short break
+ timer.skip(); // short break -> work
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+
+ test('stops the timer when skipping', () {
+ timer.start();
+ timer.skip();
+ expect(timer.state.isRunning, false);
+ });
+ });
+
+ group('dispose()', () {
+ test('cancels internal timer', () {
+ // Create a separate timer so tearDown does not double-dispose.
+ final disposableTimer = PomodoroTimer(
+ workMinutes: 1,
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 2,
+ timerFactory: fakeTimerFactory,
+ );
+ disposableTimer.start();
+ disposableTimer.dispose();
+ expect(fakeController.isActive, false);
+ });
+ });
+
+ group('applyRemoteState()', () {
+ test('applies remote state and notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+
+ final remoteState = PomodoroState(
+ mode: PomodoroMode.shortBreak,
+ remainingSeconds: 200,
+ totalSeconds: 300,
+ isRunning: false,
+ completedPomodoros: 2,
+ pomodorosPerCycle: 4,
+ );
+
+ timer.applyRemoteState(remoteState, 'pause');
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.remainingSeconds, 200);
+ expect(timer.state.completedPomodoros, 2);
+ expect(timer.state.isRunning, false);
+ expect(notified, true);
+ });
+
+ test('starts local ticking when remote state is running', () {
+ final remoteState = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 500,
+ totalSeconds: 600,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ timer.applyRemoteState(remoteState, 'start');
+ expect(timer.state.isRunning, true);
+
+ // The fake timer should have been created; ticking should work.
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 499);
+ });
+
+ test('stops local ticking when remote state is paused', () {
+ // First start the timer locally.
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 59);
+
+ // Apply remote pause.
+ final remoteState = timer.state.copyWith(isRunning: false);
+ timer.applyRemoteState(remoteState, 'pause');
+ expect(timer.state.isRunning, false);
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/sync_service_test.dart b/pomodoro_app/test/services/sync_service_test.dart
new file mode 100644
index 0000000..05ed281
--- /dev/null
+++ b/pomodoro_app/test/services/sync_service_test.dart
@@ -0,0 +1,260 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/sync_service.dart';
+
+/// A fake [RawDatagramSocket] that captures sent messages and allows
+/// injecting received messages.
+class FakeDatagramSocket implements RawDatagramSocket {
+ final _controller = StreamController.broadcast();
+ final List<_SentDatagram> sentMessages = [];
+ Datagram? _pendingDatagram;
+
+ @override
+ int send(List buffer, InternetAddress address, int port) {
+ sentMessages.add(_SentDatagram(buffer, address, port));
+ return buffer.length;
+ }
+
+ @override
+ Datagram? receive() => _pendingDatagram;
+
+ /// Simulates receiving a datagram.
+ void injectDatagram(List data, InternetAddress address, int port) {
+ _pendingDatagram = Datagram(
+ data as dynamic,
+ address,
+ port,
+ );
+ _controller.add(RawSocketEvent.read);
+ }
+
+ @override
+ StreamSubscription listen(
+ void Function(RawSocketEvent)? onData, {
+ Function? onError,
+ void Function()? onDone,
+ bool? cancelOnError,
+ }) {
+ return _controller.stream.listen(
+ onData,
+ onError: onError,
+ onDone: onDone,
+ cancelOnError: cancelOnError ?? false,
+ );
+ }
+
+ @override
+ void joinMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
+
+ @override
+ void leaveMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
+
+ @override
+ void close() => _controller.close();
+
+ // Required interface stubs.
+ @override
+ dynamic noSuchMethod(Invocation invocation) => null;
+}
+
+class _SentDatagram {
+ _SentDatagram(this.data, this.address, this.port);
+ final List data;
+ final InternetAddress address;
+ final int port;
+
+ Map get decoded =>
+ jsonDecode(utf8.decode(data)) as Map;
+}
+
+void main() {
+ group('SyncService', () {
+ late FakeDatagramSocket fakeSocket;
+ late SyncService service;
+ PomodoroState? receivedState;
+ String? receivedAction;
+
+ setUp(() async {
+ fakeSocket = FakeDatagramSocket();
+ receivedState = null;
+ receivedAction = null;
+
+ service = SyncService(
+ onStateReceived: (state, action) {
+ receivedState = state;
+ receivedAction = action;
+ },
+ deviceId: 'test-device-1',
+ socketFactory: (host, port) async => fakeSocket,
+ );
+ await service.start();
+ });
+
+ tearDown(() async {
+ await service.dispose();
+ });
+
+ test('is active after start', () {
+ expect(service.isActive, true);
+ });
+
+ test('broadcast sends a message', () {
+ // start() sends a wake message, so clear before testing broadcast.
+ fakeSocket.sentMessages.clear();
+
+ final state = PomodoroState.initial();
+ service.broadcast(state, 'start');
+
+ expect(fakeSocket.sentMessages, hasLength(1));
+ final decoded = fakeSocket.sentMessages.first.decoded;
+ expect(decoded['deviceId'], 'test-device-1');
+ expect(decoded['action'], 'start');
+ expect(decoded['state']['mode'], 'work');
+ expect(decoded['state']['remainingSeconds'], 25 * 60);
+ });
+
+ test('ignores own messages', () async {
+ final state = PomodoroState.initial();
+ final message = jsonEncode({
+ 'deviceId': 'test-device-1', // Same as our device.
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': 'start',
+ 'state': {
+ 'mode': 'work',
+ 'remainingSeconds': 1500,
+ 'totalSeconds': 1500,
+ 'isRunning': true,
+ 'completedPomodoros': 0,
+ 'pomodorosPerCycle': 4,
+ },
+ });
+
+ fakeSocket.injectDatagram(
+ utf8.encode(message),
+ InternetAddress('192.168.1.100'),
+ 41234,
+ );
+
+ // Allow async processing.
+ await Future.delayed(Duration.zero);
+ expect(receivedState, isNull);
+ expect(receivedAction, isNull);
+ });
+
+ test('processes messages from other devices', () async {
+ final message = jsonEncode({
+ 'deviceId': 'other-device-2',
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': 'pause',
+ 'state': {
+ 'mode': 'work',
+ 'remainingSeconds': 1200,
+ 'totalSeconds': 1500,
+ 'isRunning': false,
+ 'completedPomodoros': 1,
+ 'pomodorosPerCycle': 4,
+ },
+ });
+
+ fakeSocket.injectDatagram(
+ utf8.encode(message),
+ InternetAddress('192.168.1.101'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ expect(receivedState, isNotNull);
+ expect(receivedAction, 'pause');
+ expect(receivedState!.remainingSeconds, 1200);
+ expect(receivedState!.isRunning, false);
+ expect(receivedState!.completedPomodoros, 1);
+ });
+
+ test('handles malformed messages gracefully', () async {
+ fakeSocket.injectDatagram(
+ utf8.encode('not json at all'),
+ InternetAddress('192.168.1.101'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ // Should not crash, receivedState stays null.
+ expect(receivedState, isNull);
+ });
+
+ test('broadcast does nothing after dispose', () async {
+ await service.dispose();
+ expect(service.isActive, false);
+
+ // Should not throw.
+ service.broadcast(PomodoroState.initial(), 'start');
+ });
+
+ test('heartbeat sends periodic state', () async {
+ final state = PomodoroState.initial();
+ service.startHeartbeat(() => state);
+
+ // Wait for at least one heartbeat interval.
+ // Note: In tests, Timer.periodic fires based on the test framework.
+ // We just verify it doesn't crash and can be stopped.
+ service.stopHeartbeat();
+ });
+ });
+
+ group('SyncService state encoding', () {
+ test('all modes encode and decode correctly', () async {
+ for (final mode in PomodoroMode.values) {
+ final fakeSocket = FakeDatagramSocket();
+ PomodoroState? received;
+
+ final sender = SyncService(
+ onStateReceived: (_, __) {},
+ deviceId: 'sender',
+ socketFactory: (h, p) async => fakeSocket,
+ );
+ await sender.start();
+
+ final receiver = SyncService(
+ onStateReceived: (state, action) => received = state,
+ deviceId: 'receiver',
+ socketFactory: (h, p) async => fakeSocket,
+ );
+ await receiver.start();
+
+ final state = PomodoroState(
+ mode: mode,
+ remainingSeconds: 300,
+ totalSeconds: 600,
+ isRunning: true,
+ completedPomodoros: 3,
+ pomodorosPerCycle: 4,
+ );
+
+ sender.broadcast(state, 'test');
+
+ // Manually decode the sent message and inject it.
+ final sent = fakeSocket.sentMessages.last;
+ fakeSocket.injectDatagram(
+ sent.data,
+ InternetAddress('192.168.1.100'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ expect(received, isNotNull);
+ expect(received!.mode, mode);
+ expect(received!.remainingSeconds, 300);
+ expect(received!.totalSeconds, 600);
+ expect(received!.isRunning, true);
+ expect(received!.completedPomodoros, 3);
+
+ await sender.dispose();
+ await receiver.dispose();
+ }
+ });
+ });
+}