fixes for existing scripts and pomodoro with local sync

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-02-14 18:42:20 +01:00
parent fe2c6628e2
commit f68a5cc3a7
46 changed files with 2311 additions and 0 deletions

45
pomodoro_app/.gitignore vendored Normal file
View File

@ -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

33
pomodoro_app/.metadata Normal file
View File

@ -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'

17
pomodoro_app/README.md Normal file
View File

@ -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.

View File

@ -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

14
pomodoro_app/android/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="pomodoro_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -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()
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -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

View File

@ -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")

1
pomodoro_app/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

View File

@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()

View File

@ -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}
)

View File

@ -0,0 +1,11 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)

View File

@ -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}")

View File

@ -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);
}

View File

@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#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));
}

View File

@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
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_

View File

@ -0,0 +1,2 @@
pkg/
*.pkg.tar.zst

View File

@ -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"
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/pomodoro-app/pomodoro_app "$@"

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Tomato body -->
<ellipse cx="128" cy="148" rx="100" ry="90" fill="#e74c3c"/>
<!-- Highlight -->
<ellipse cx="100" cy="120" rx="30" ry="40" fill="#ec7063" opacity="0.5"/>
<!-- Stem -->
<rect x="120" y="52" width="16" height="24" rx="4" fill="#27ae60"/>
<!-- Leaf -->
<path d="M136 64 Q160 40 156 62 Q150 72 136 68Z" fill="#2ecc71"/>
<!-- Clock face -->
<circle cx="128" cy="152" r="50" fill="none" stroke="#fff" stroke-width="4" opacity="0.85"/>
<!-- Minute hand -->
<line x1="128" y1="152" x2="128" y2="112" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.9"/>
<!-- Hour hand -->
<line x1="128" y1="152" x2="152" y2="140" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.9"/>
<!-- Center dot -->
<circle cx="128" cy="152" r="4" fill="#fff" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

View File

@ -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()

View File

@ -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

213
pomodoro_app/pubspec.lock Normal file
View File

@ -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"

88
pomodoro_app/pubspec.yaml Normal file
View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<RawSocketEvent>.broadcast();
final List<_SentDatagram> sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List<int> 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<int> data, InternetAddress address, int port) {
_pendingDatagram = Datagram(
data as dynamic,
address,
port,
);
_controller.add(RawSocketEvent.read);
}
@override
StreamSubscription<RawSocketEvent> 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<int> data;
final InternetAddress address;
final int port;
Map<String, dynamic> get decoded =>
jsonDecode(utf8.decode(data)) as Map<String, dynamic>;
}
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<void>.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<void>.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<void>.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<void>.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();
}
});
});
}