commit 164f7ccad79b0c4f4810c8fba09195e8e33a5354 Author: zsolt Date: Thu Aug 13 00:13:13 2026 +0200 Initial Közös Tér and JARVIS API codebase diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dea448e --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# 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/ +*.tgz +/deploy/gitlab/ +/deploy/gitea/ + +# 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 + +**/bin/ +**/obj/ +android/build/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..2459a95 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# 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: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: linux + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: macos + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: web + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: windows + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # 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/README.md b/README.md new file mode 100644 index 0000000..0e3a04b --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# otthon_naptar + +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/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/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/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/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/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..3aa703a --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "hu.otthonnaptar.otthon_naptar" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "hu.otthonnaptar.otthon_naptar" + // 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") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9c15475 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/hu/otthonnaptar/otthon_naptar/MainActivity.kt b/android/app/src/main/kotlin/hu/otthonnaptar/otthon_naptar/MainActivity.kt new file mode 100644 index 0000000..9b15f97 --- /dev/null +++ b/android/app/src/main/kotlin/hu/otthonnaptar/otthon_naptar/MainActivity.kt @@ -0,0 +1,5 @@ +package hu.otthonnaptar.otthon_naptar + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..af843a7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d524d6b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..47b980f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b99e1df Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4ba74de Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/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/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..4f7ba29 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.overridePathCheck=true +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/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-9.1.0-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/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 "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/api/OtthonApi/.dockerignore b/api/OtthonApi/.dockerignore new file mode 100644 index 0000000..54c45ec --- /dev/null +++ b/api/OtthonApi/.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +.vs/ +.vscode/ +*.user +*.suo diff --git a/api/OtthonApi/Dockerfile b/api/OtthonApi/Dockerfile new file mode 100644 index 0000000..e206401 --- /dev/null +++ b/api/OtthonApi/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY OtthonApi.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "OtthonApi.dll"] diff --git a/api/OtthonApi/OtthonApi.csproj b/api/OtthonApi/OtthonApi.csproj new file mode 100644 index 0000000..458b82f --- /dev/null +++ b/api/OtthonApi/OtthonApi.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/api/OtthonApi/OtthonApi.http b/api/OtthonApi/OtthonApi.http new file mode 100644 index 0000000..6a4d98b --- /dev/null +++ b/api/OtthonApi/OtthonApi.http @@ -0,0 +1,6 @@ +@OtthonApi_HostAddress = http://localhost:5278 + +GET {{OtthonApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/api/OtthonApi/Program.cs b/api/OtthonApi/Program.cs new file mode 100644 index 0000000..65f3707 --- /dev/null +++ b/api/OtthonApi/Program.cs @@ -0,0 +1,1009 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); +builder.Services.AddHttpClient("open-food-facts", client => +{ + client.BaseAddress = new Uri("https://world.openfoodfacts.org/"); + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("JarvisApi", "0.1")); +}); + +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration.GetConnectionString("Default") + ?? "Server=localhost,14333;Database=JarvisHome;User Id=sa;Password=Jarvis!2026Api;TrustServerCertificate=True;Encrypt=False"; + options.UseSqlServer(connectionString); +}); + +builder.Services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/logout"; + options.Cookie.Name = "jarvis_admin"; + }); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider + .GetRequiredService() + .CreateLogger("JarvisStartup"); + await EnsureDatabaseReady(db, logger); +} + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/health", () => Results.Ok(new +{ + status = "ok", + utc = DateTimeOffset.UtcNow +})); + +app.MapGet("/", () => Results.Redirect("/devices")); + +app.MapGet("/login", () => Results.Content(LoginPage(), "text/html; charset=utf-8")); + +app.MapPost("/login", async ( + HttpContext context, + [FromForm] LoginForm form, + IConfiguration config) => +{ + var expected = config["OTTHON_ADMIN_PASSWORD"] ?? "change-me-admin"; + if (form.Password != expected) + { + return Results.Content(LoginPage("Invalid password."), "text/html; charset=utf-8"); + } + + var claims = new[] { new Claim(ClaimTypes.Name, "admin") }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity)); + + return Results.Redirect("/devices"); +}).DisableAntiforgery(); + +app.MapPost("/logout", async (HttpContext context) => +{ + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/login"); +}).RequireAuthorization(); + +app.MapGet("/devices", async (OtthonDbContext db) => +{ + var devices = await db.Devices + .OrderByDescending(device => device.LastSeenUtc ?? device.CreatedUtc) + .ToListAsync(); + return Results.Content(DevicesPage(devices), "text/html; charset=utf-8"); +}).RequireAuthorization(); + +app.MapPost("/devices/{id:int}/deactivate", async (int id, OtthonDbContext db) => +{ + var device = await db.Devices.FindAsync(id); + if (device is not null) + { + device.IsActive = false; + await db.SaveChangesAsync(); + } + + return Results.Redirect("/devices"); +}).RequireAuthorization().DisableAntiforgery(); + +app.MapPost("/devices/{id:int}/approve", async (int id, OtthonDbContext db) => +{ + var device = await db.Devices.FindAsync(id); + if (device is not null) + { + device.IsActive = true; + device.LastSeenUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + return Results.Redirect("/devices"); +}).RequireAuthorization().DisableAntiforgery(); + +app.MapPost("/api/devices/register", async ( + DeviceRegistrationRequest request, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var device = new RegisteredDevice + { + Name = string.IsNullOrWhiteSpace(request.DeviceName) + ? "Tablet" + : request.DeviceName.Trim(), + Platform = string.IsNullOrWhiteSpace(request.Platform) + ? "unknown" + : request.Platform.Trim(), + DeviceToken = Guid.NewGuid().ToString("N"), + CreatedUtc = DateTimeOffset.UtcNow, + LastSeenUtc = null, + IsActive = false + }; + + db.Devices.Add(device); + await db.SaveChangesAsync(); + return Results.Ok(new + { + device.Id, + device.Name, + device.Platform, + device.DeviceToken, + status = "pending" + }); +}); + +app.MapGet("/api/devices/{id:int}/status", async ( + int id, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var device = await db.Devices.FindAsync(id); + if (device is null) + { + return Results.Ok(new { found = false, status = "notRegistered" }); + } + + return Results.Ok(new + { + found = true, + device.Id, + device.Name, + status = device.IsActive ? "approved" : "pending" + }); +}); + +app.MapGet("/api/products/{barcode}", async ( + string barcode, + bool? refresh, + HttpContext context, + OtthonDbContext db, + IHttpClientFactory httpClientFactory, + ILoggerFactory loggerFactory) => +{ + var logger = loggerFactory.CreateLogger("ProductLookup"); + if (!HasApiToken(context)) + { + logger.LogWarning("Unauthorized product lookup for raw barcode {Barcode}.", barcode); + return Results.Unauthorized(); + } + + var normalized = NormalizeBarcode(barcode); + if (string.IsNullOrWhiteSpace(normalized)) + { + return Results.BadRequest(new { message = "Missing barcode." }); + } + + logger.LogInformation( + "Product lookup started. RawBarcode={RawBarcode}, Barcode={Barcode}, Refresh={Refresh}.", + barcode, + normalized, + refresh == true); + + var cached = await db.Products.FindAsync(normalized); + if (cached is not null && refresh != true) + { + logger.LogInformation( + "Product lookup cache hit. Barcode={Barcode}, Name={Name}.", + normalized, + cached.Name); + return Results.Ok(ProductResponse.From(cached, true)); + } + + var lookup = await TryFetchProductFromOpenFoodFacts(normalized, httpClientFactory); + if (lookup is null) + { + logger.LogInformation( + "Product lookup miss. Barcode={Barcode}, Cached={Cached}.", + normalized, + cached is not null); + if (cached is not null) + { + return Results.Ok(ProductResponse.From(cached, true, "External refresh failed; returning cached product.")); + } + + return Results.NotFound(new + { + found = false, + barcode = normalized, + source = "openfoodfacts", + message = "Product was not found locally or in Open Food Facts." + }); + } + + var product = cached ?? lookup; + ApplyProductLookup(product, lookup, cached is null ? "openfoodfacts" : "openfoodfacts-refresh"); + if (cached is null) + { + db.Products.Add(product); + } + + await db.SaveChangesAsync(); + logger.LogInformation( + "Product lookup saved. Barcode={Barcode}, Name={Name}, Source={Source}.", + normalized, + product.Name, + product.Source); + return Results.Ok(ProductResponse.From(product, false)); +}); + +app.MapGet("/api/products", async ( + string? query, + int? take, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var limit = Math.Clamp(take ?? 50, 1, 200); + var products = db.Products.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query)) + { + var term = query.Trim(); + products = products.Where(product => + product.Barcode.Contains(term) + || product.Name.Contains(term) + || (product.Brand != null && product.Brand.Contains(term))); + } + + var rows = await products + .OrderByDescending(product => product.LastLookupUtc) + .Take(limit) + .ToListAsync(); + var result = rows.Select(product => ProductResponse.From(product, true)).ToList(); + + return Results.Ok(new { count = result.Count, products = result }); +}); + +app.MapPost("/api/products", async ( + ProductUpsertRequest request, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var barcode = NormalizeBarcode(request.Barcode); + if (string.IsNullOrWhiteSpace(barcode) || string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { message = "Barcode and name are required." }); + } + + var product = await db.Products.FindAsync(barcode); + if (product is null) + { + product = new Product + { + Barcode = barcode, + Name = request.Name.Trim(), + Source = "manual" + }; + db.Products.Add(product); + } + + product.Name = request.Name.Trim(); + product.Brand = Clean(request.Brand); + product.Quantity = Clean(request.Quantity); + product.ImageUrl = Clean(request.ImageUrl); + product.Categories = Clean(request.Categories); + product.Labels = Clean(request.Labels); + product.NutriScore = Clean(request.NutriScore); + product.Ecoscore = Clean(request.Ecoscore); + product.IngredientsText = Clean(request.IngredientsText); + product.Source = "manual"; + product.LastLookupUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(ProductResponse.From(product, true)); +}); + +app.MapDelete("/api/products/{barcode}", async ( + string barcode, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var normalized = NormalizeBarcode(barcode); + var product = await db.Products.FindAsync(normalized); + if (product is null) + { + return Results.NotFound(new { found = false, barcode = normalized }); + } + + db.Products.Remove(product); + await db.SaveChangesAsync(); + return Results.Ok(new { deleted = true, barcode = normalized }); +}); + +app.MapPost("/api/food-events", async ( + FoodLearningEventRequest request, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var productName = Clean(request.ProductName); + var barcode = Clean(request.Barcode) is { } rawBarcode + ? NormalizeBarcode(rawBarcode) + : null; + if (string.IsNullOrWhiteSpace(productName) && string.IsNullOrWhiteSpace(barcode)) + { + return Results.BadRequest(new { message = "Product name or barcode is required." }); + } + + if (string.IsNullOrWhiteSpace(productName) && !string.IsNullOrWhiteSpace(barcode)) + { + productName = (await db.Products.FindAsync(barcode))?.Name; + } + + if (string.IsNullOrWhiteSpace(productName)) + { + return Results.BadRequest(new { message = "Product name could not be resolved." }); + } + + var occurredUtc = request.OccurredUtc ?? DateTimeOffset.UtcNow; + var localTime = request.LocalTime ?? occurredUtc.ToLocalTime(); + var learningEvent = new FoodLearningEvent + { + ProductName = productName.Trim(), + Barcode = string.IsNullOrWhiteSpace(barcode) ? null : barcode, + Action = Clean(request.Action) ?? "stock-added", + Amount = request.Amount, + Unit = Clean(request.Unit), + Location = Clean(request.Location), + OccurredUtc = occurredUtc, + LocalHour = localTime.Hour, + DayOfMonth = localTime.Day, + Month = localTime.Month, + DayOfWeek = (int)localTime.DayOfWeek, + CreatedUtc = DateTimeOffset.UtcNow + }; + + db.FoodLearningEvents.Add(learningEvent); + await db.SaveChangesAsync(); + + return Results.Ok(FoodLearningEventResponse.From(learningEvent)); +}); + +app.MapGet("/api/food-events", async ( + int? take, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var limit = Math.Clamp(take ?? 50, 1, 200); + var events = await db.FoodLearningEvents + .AsNoTracking() + .OrderByDescending(item => item.OccurredUtc) + .Take(limit) + .ToListAsync(); + + return Results.Ok(new + { + count = events.Count, + events = events.Select(FoodLearningEventResponse.From) + }); +}); + +app.MapGet("/api/shopping/recommendations", async ( + int? take, + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var now = DateTimeOffset.Now; + var limit = Math.Clamp(take ?? 12, 1, 50); + var events = await db.FoodLearningEvents + .AsNoTracking() + .Where(item => item.OccurredUtc >= DateTimeOffset.UtcNow.AddDays(-180)) + .OrderByDescending(item => item.OccurredUtc) + .Take(1000) + .ToListAsync(); + + var recommendations = events + .GroupBy(item => NormalizeFoodKey(item.Barcode, item.ProductName)) + .Select(group => BuildRecommendation(group, now)) + .OrderByDescending(item => item.Score) + .ThenBy(item => item.ProductName) + .Take(limit) + .ToList(); + + return Results.Ok(new + { + generatedAt = now, + count = recommendations.Count, + recommendations + }); +}); + +app.Run(); + +static bool HasApiToken(HttpContext context) +{ + var expected = context.RequestServices + .GetRequiredService()["OTTHON_API_TOKEN"] ?? "change-me-local-token"; + + if (context.Request.Headers.TryGetValue("X-Otthon-Token", out var headerToken) + && headerToken == expected) + { + return true; + } + + if (context.Request.Query.TryGetValue("token", out var queryToken) + && queryToken == expected) + { + return true; + } + + return false; +} + +static string NormalizeBarcode(string barcode) => + new(barcode.Where(char.IsDigit).ToArray()); + +static string? Clean(string? value) +{ + var trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; +} + +static void ApplyProductLookup(Product target, Product lookup, string source) +{ + target.Name = lookup.Name; + target.Brand = lookup.Brand; + target.Quantity = lookup.Quantity; + target.ImageUrl = lookup.ImageUrl; + target.Categories = lookup.Categories; + target.Labels = lookup.Labels; + target.NutriScore = lookup.NutriScore; + target.Ecoscore = lookup.Ecoscore; + target.IngredientsText = lookup.IngredientsText; + target.Source = source; + target.LastLookupUtc = DateTimeOffset.UtcNow; +} + +static ShoppingRecommendationResponse BuildRecommendation( + IGrouping group, + DateTimeOffset now) +{ + var rows = group.OrderByDescending(item => item.OccurredUtc).ToList(); + var latest = rows[0]; + var frequencyScore = Math.Min(rows.Count * 8, 60); + var daysSinceLatest = Math.Max(0, (DateTimeOffset.UtcNow - latest.OccurredUtc).TotalDays); + var recencyScore = Math.Max(0, 35 - daysSinceLatest); + var hourScore = rows.Count(item => Math.Abs(item.LocalHour - now.Hour) <= 2) * 3; + var dayOfMonthScore = rows.Count(item => Math.Abs(item.DayOfMonth - now.Day) <= 2) * 4; + var monthScore = rows.Count(item => item.Month == now.Month) * 2; + var score = Math.Round(frequencyScore + recencyScore + hourScore + dayOfMonthScore + monthScore, 2); + var reasons = new List + { + $"{rows.Count} kor\u00e1bbi el\u0151fordul\u00e1s", + $"legut\u00f3bb: {latest.OccurredUtc.LocalDateTime:yyyy.MM.dd HH:mm}" + }; + if (hourScore > 0) + { + reasons.Add("hasonl\u00f3 napszakban gyakori"); + } + + if (dayOfMonthScore > 0) + { + reasons.Add("h\u00f3napon bel\u00fcl mostan\u00e1ban szokott el\u0151j\u00f6nni"); + } + + if (monthScore > 0) + { + reasons.Add("ebben a h\u00f3napban m\u00e1r volt r\u00e1 minta"); + } + + return new ShoppingRecommendationResponse( + latest.ProductName, + latest.Barcode, + latest.Unit, + latest.Location, + score, + rows.Count, + latest.OccurredUtc, + reasons); +} + +static string NormalizeFoodKey(string? barcode, string productName) +{ + if (!string.IsNullOrWhiteSpace(barcode)) + { + return $"barcode:{barcode}"; + } + + var letters = productName + .Trim() + .ToLowerInvariant() + .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) + .ToArray(); + return $"name:{new string(letters)}"; +} + +static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) +{ + const int maxAttempts = 30; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + await db.Database.EnsureCreatedAsync(); + await EnsureProductSchemaAsync(db); + await EnsureFoodLearningSchemaAsync(db); + return; + } + catch (Exception ex) when (attempt < maxAttempts) + { + logger.LogWarning( + ex, + "JARVIS API is waiting for the database. Attempt {Attempt}/{MaxAttempts}.", + attempt, + maxAttempts); + await Task.Delay(TimeSpan.FromSeconds(3)); + } + } + + await db.Database.EnsureCreatedAsync(); + await EnsureProductSchemaAsync(db); + await EnsureFoodLearningSchemaAsync(db); +} + +static async Task EnsureProductSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF COL_LENGTH('Products', 'Categories') IS NULL + ALTER TABLE Products ADD Categories nvarchar(1000) NULL; +IF COL_LENGTH('Products', 'Labels') IS NULL + ALTER TABLE Products ADD Labels nvarchar(1000) NULL; +IF COL_LENGTH('Products', 'NutriScore') IS NULL + ALTER TABLE Products ADD NutriScore nvarchar(32) NULL; +IF COL_LENGTH('Products', 'Ecoscore') IS NULL + ALTER TABLE Products ADD Ecoscore nvarchar(32) NULL; +IF COL_LENGTH('Products', 'IngredientsText') IS NULL + ALTER TABLE Products ADD IngredientsText nvarchar(max) NULL; +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + +static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF OBJECT_ID('FoodLearningEvents', 'U') IS NULL +BEGIN + CREATE TABLE FoodLearningEvents ( + Id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FoodLearningEvents PRIMARY KEY, + ProductName nvarchar(300) NOT NULL, + Barcode nvarchar(64) NULL, + Action nvarchar(80) NOT NULL, + Amount int NULL, + Unit nvarchar(80) NULL, + Location nvarchar(120) NULL, + OccurredUtc datetimeoffset NOT NULL, + LocalHour int NOT NULL, + DayOfMonth int NOT NULL, + Month int NOT NULL, + DayOfWeek int NOT NULL, + CreatedUtc datetimeoffset NOT NULL + ); +END +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + +static async Task TryFetchProductFromOpenFoodFacts( + string barcode, + IHttpClientFactory httpClientFactory) +{ + var client = httpClientFactory.CreateClient("open-food-facts"); + using var response = await client.GetAsync( + $"api/v2/product/{barcode}.json?fields=code,product_name,product_name_hu,product_name_en,generic_name,brands,quantity,image_front_url,image_url,categories,categories_tags,labels,labels_tags,nutriscore_grade,ecoscore_grade,ingredients_text,ingredients_text_hu,ingredients_text_en"); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + if (!document.RootElement.TryGetProperty("status", out var status) + || status.GetInt32() != 1 + || !document.RootElement.TryGetProperty("product", out var productJson)) + { + return null; + } + + var name = GetString(productJson, "product_name_hu") + ?? GetString(productJson, "product_name") + ?? GetString(productJson, "product_name_en") + ?? GetString(productJson, "generic_name"); + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + return new Product + { + Barcode = barcode, + Name = name, + Brand = GetString(productJson, "brands"), + Quantity = GetString(productJson, "quantity"), + ImageUrl = GetString(productJson, "image_front_url") + ?? GetString(productJson, "image_url"), + Categories = GetString(productJson, "categories") + ?? GetTagList(productJson, "categories_tags"), + Labels = GetString(productJson, "labels") + ?? GetTagList(productJson, "labels_tags"), + NutriScore = GetString(productJson, "nutriscore_grade")?.ToUpperInvariant(), + Ecoscore = GetString(productJson, "ecoscore_grade")?.ToUpperInvariant(), + IngredientsText = GetString(productJson, "ingredients_text_hu") + ?? GetString(productJson, "ingredients_text") + ?? GetString(productJson, "ingredients_text_en"), + Source = "openfoodfacts", + LastLookupUtc = DateTimeOffset.UtcNow + }; +} + +static string? GetTagList(JsonElement element, string propertyName) +{ + if (!element.TryGetProperty(propertyName, out var property) + || property.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = property + .EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!.Contains(':') ? value[(value.IndexOf(':') + 1)..] : value) + .ToArray(); + + return values.Length == 0 ? null : string.Join(", ", values); +} + +static string? GetString(JsonElement element, string propertyName) +{ + if (!element.TryGetProperty(propertyName, out var property) + || property.ValueKind != JsonValueKind.String) + { + return null; + } + + var value = property.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; +} + +static string LoginPage(string? error = null) => $$""" + + + + + + JARVIS API login + + + +
+

JARVIS API

+

Admin login for device management.

+ {{(error is null ? "" : $"
{error}
")}} +
+ + + +
+
+ + +"""; + +static string DevicesPage(IReadOnlyList devices) +{ + var rows = string.Join("", devices.Select(device => $$""" + + {{Html(device.Name)}} + {{Html(device.Platform)}} + {{Html(device.DeviceToken)}} + {{device.CreatedUtc.LocalDateTime:yyyy.MM.dd HH:mm}} + {{(device.LastSeenUtc?.LocalDateTime.ToString("yyyy.MM.dd HH:mm") ?? "-")}} + {{(device.IsActive ? "approved" : "pending approval")}} + +
+ +
+
+ +
+ + +""")); + + return $$""" + + + + + + Devices + + + +
+
+
+

JARVIS API

+

Registered devices

+
+
+
+

The app can submit a device request through POST /api/devices/register. New devices stay pending until approved here.

+ + + + + + + + + + + + + {{(rows.Length == 0 ? "" : rows)}} +
NamePlatformTokenCreatedLast seenStatus
No registered devices yet.
+
+ + +"""; +} + +static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); + +static string AdminCss() => """ +:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; } +body { margin: 0; min-height: 100vh; background: #f4f1ea; color: #17342d; display: grid; place-items: center; } +.card { width: min(1120px, calc(100vw - 40px)); background: #fff; border-radius: 28px; padding: 28px; box-shadow: 0 24px 60px rgba(47, 107, 91, .16); } +.narrow { width: min(420px, calc(100vw - 40px)); } +.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.eyebrow { margin: 0 0 4px; color: #2f6b5b; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +h1 { margin: 0 0 18px; font-size: clamp(28px, 4vw, 44px); } +p { color: #5b6d67; } +label { display: block; margin-bottom: 8px; font-weight: 800; } +input { width: 100%; box-sizing: border-box; border: 1px solid #d9e2de; border-radius: 16px; padding: 14px; font-size: 16px; } +button { border: 0; border-radius: 999px; padding: 12px 18px; background: #0f7b61; color: white; font-weight: 900; cursor: pointer; margin-top: 14px; } +button.secondary { background: #e6f1ed; color: #17342d; margin-top: 0; } +button.approve { background: #0f7b61; color: white; margin-top: 0; margin-right: 8px; } +button:disabled { opacity: .35; cursor: not-allowed; } +td form { display: inline-block; margin: 0; } +.error { background: #ffe8e0; color: #9b341f; padding: 12px 14px; border-radius: 14px; margin-bottom: 14px; } +.hint { background: #e6f1ed; padding: 14px 16px; border-radius: 18px; } +table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 18px; } +th, td { text-align: left; padding: 12px; border-bottom: 1px solid #edf2ef; vertical-align: top; } +th { font-size: 12px; text-transform: uppercase; color: #688078; letter-spacing: .08em; } +code { background: #f3f7f5; padding: 3px 6px; border-radius: 8px; } +.status { display: inline-block; border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 900; } +.status.approved { background: #e3f4ed; color: #0f7b61; } +.status.pending { background: #fff0d4; color: #9b6412; } +"""; + +public sealed class OtthonDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet Devices => Set(); + public DbSet Products => Set(); + public DbSet FoodLearningEvents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(product => product.Barcode); + modelBuilder.Entity().Property(product => product.Barcode).HasMaxLength(64); + modelBuilder.Entity().Property(product => product.Name).HasMaxLength(300); + modelBuilder.Entity().Property(product => product.Brand).HasMaxLength(300); + modelBuilder.Entity().Property(product => product.Quantity).HasMaxLength(120); + modelBuilder.Entity().Property(product => product.ImageUrl).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.Categories).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.Labels).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.NutriScore).HasMaxLength(32); + modelBuilder.Entity().Property(product => product.Ecoscore).HasMaxLength(32); + modelBuilder.Entity().Property(device => device.Name).HasMaxLength(120); + modelBuilder.Entity().Property(device => device.DeviceToken).HasMaxLength(64); + modelBuilder.Entity().Property(item => item.ProductName).HasMaxLength(300); + modelBuilder.Entity().Property(item => item.Barcode).HasMaxLength(64); + modelBuilder.Entity().Property(item => item.Action).HasMaxLength(80); + modelBuilder.Entity().Property(item => item.Unit).HasMaxLength(80); + modelBuilder.Entity().Property(item => item.Location).HasMaxLength(120); + } +} + +public sealed class RegisteredDevice +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string Platform { get; set; } + public required string DeviceToken { get; set; } + public DateTimeOffset CreatedUtc { get; set; } + public DateTimeOffset? LastSeenUtc { get; set; } + public bool IsActive { get; set; } = true; +} + +public sealed class Product +{ + public required string Barcode { get; set; } + public required string Name { get; set; } + public string? Brand { get; set; } + public string? Quantity { get; set; } + public string? ImageUrl { get; set; } + public string? Categories { get; set; } + public string? Labels { get; set; } + public string? NutriScore { get; set; } + public string? Ecoscore { get; set; } + public string? IngredientsText { get; set; } + public string Source { get; set; } = "manual"; + public DateTimeOffset LastLookupUtc { get; set; } +} + +public sealed class FoodLearningEvent +{ + public int Id { get; set; } + public required string ProductName { get; set; } + public string? Barcode { get; set; } + public required string Action { get; set; } + public int? Amount { get; set; } + public string? Unit { get; set; } + public string? Location { get; set; } + public DateTimeOffset OccurredUtc { get; set; } + public int LocalHour { get; set; } + public int DayOfMonth { get; set; } + public int Month { get; set; } + public int DayOfWeek { get; set; } + public DateTimeOffset CreatedUtc { get; set; } +} + +public sealed record LoginForm(string Password); +public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); +public sealed record ProductUpsertRequest( + string Barcode, + string Name, + string? Brand, + string? Quantity, + string? ImageUrl, + string? Categories, + string? Labels, + string? NutriScore, + string? Ecoscore, + string? IngredientsText); + +public sealed record FoodLearningEventRequest( + string? ProductName, + string? Barcode, + string? Action, + int? Amount, + string? Unit, + string? Location, + DateTimeOffset? OccurredUtc, + DateTimeOffset? LocalTime); + +public sealed record FoodLearningEventResponse( + int Id, + string ProductName, + string? Barcode, + string Action, + int? Amount, + string? Unit, + string? Location, + DateTimeOffset OccurredUtc, + int LocalHour, + int DayOfMonth, + int Month, + int DayOfWeek) +{ + public static FoodLearningEventResponse From(FoodLearningEvent item) => new( + item.Id, + item.ProductName, + item.Barcode, + item.Action, + item.Amount, + item.Unit, + item.Location, + item.OccurredUtc, + item.LocalHour, + item.DayOfMonth, + item.Month, + item.DayOfWeek); +} + +public sealed record ShoppingRecommendationResponse( + string ProductName, + string? Barcode, + string? Unit, + string? Location, + double Score, + int Occurrences, + DateTimeOffset LastSeenUtc, + IReadOnlyList Reasons); + +public sealed record ProductResponse( + bool Found, + string Barcode, + string Name, + string? Brand, + string? Quantity, + string? ImageUrl, + string? Categories, + string? Labels, + string? NutriScore, + string? Ecoscore, + string? IngredientsText, + string Source, + bool FromCache, + DateTimeOffset LastLookupUtc, + string? Message = null) +{ + public static ProductResponse From(Product product, bool fromCache, string? message = null) => new( + true, + product.Barcode, + product.Name, + product.Brand, + product.Quantity, + product.ImageUrl, + product.Categories, + product.Labels, + product.NutriScore, + product.Ecoscore, + product.IngredientsText, + product.Source, + fromCache, + product.LastLookupUtc, + message); +} diff --git a/api/OtthonApi/Properties/launchSettings.json b/api/OtthonApi/Properties/launchSettings.json new file mode 100644 index 0000000..9341a63 --- /dev/null +++ b/api/OtthonApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/api/OtthonApi/appsettings.Development.json b/api/OtthonApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/api/OtthonApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/api/OtthonApi/appsettings.json b/api/OtthonApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/api/OtthonApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/api/OtthonBackend.sln b/api/OtthonBackend.sln new file mode 100644 index 0000000..006250f --- /dev/null +++ b/api/OtthonBackend.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtthonApi", "OtthonApi\OtthonApi.csproj", "{CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x64.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x86.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|Any CPU.Build.0 = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x64.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x64.Build.0 = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x86.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..f112930 --- /dev/null +++ b/api/README.md @@ -0,0 +1,23 @@ +# JARVIS API + +Inditas Docker Desktopon: + +```powershell +docker compose -f api\docker-compose.yml up -d --build +``` + +Helyi cimek: + +- API: `http://localhost:5088` +- Admin felulet: `http://localhost:5088/devices` +- Admin jelszo: `change-me-admin` +- API token: `change-me-local-token` + +Elesebb hasznalathoz a `docker-compose.yml` fajlban csereld le az +`OTTHON_API_TOKEN`, `OTTHON_ADMIN_PASSWORD` es `MSSQL_SA_PASSWORD` ertekeket. + +Pelda termekkeresesre: + +```powershell +curl -H "X-Otthon-Token: change-me-local-token" http://localhost:5088/api/products/5449000000996 +``` diff --git a/api/deploy-server.sh b/api/deploy-server.sh new file mode 100644 index 0000000..ba26729 --- /dev/null +++ b/api/deploy-server.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /root/docker/jarvis-api +cd /root/docker/jarvis-api +tar xzf jarvis-api-deploy.tgz + +random_text() { + tr -dc 'A-Za-z0-9' .env + chmod 600 .env +fi + +docker compose -f docker-compose.server.yml --env-file .env up -d --build +docker compose -f docker-compose.server.yml --env-file .env ps diff --git a/api/docker-compose.server.yml b/api/docker-compose.server.yml new file mode 100644 index 0000000..2a288c4 --- /dev/null +++ b/api/docker-compose.server.yml @@ -0,0 +1,28 @@ +services: + jarvis-api: + build: + context: ./OtthonApi + container_name: jarvis-api + restart: unless-stopped + depends_on: + - mssql + environment: + ASPNETCORE_URLS: http://+:8080 + OTTHON_API_TOKEN: ${JARVIS_API_TOKEN} + OTTHON_ADMIN_PASSWORD: ${JARVIS_ADMIN_PASSWORD} + ConnectionStrings__Default: Server=mssql,1433;Database=JarvisHome;User Id=sa;Password=${JARVIS_SA_PASSWORD};TrustServerCertificate=True;Encrypt=False + ports: + - "127.0.0.1:5088:8080" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: jarvis-mssql + restart: unless-stopped + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${JARVIS_SA_PASSWORD} + volumes: + - jarvis-mssql-data:/var/opt/mssql + +volumes: + jarvis-mssql-data: diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..9ee1f3c --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,30 @@ +services: + jarvis-api: + build: + context: ./OtthonApi + container_name: jarvis-api + restart: unless-stopped + depends_on: + - mssql + environment: + ASPNETCORE_URLS: http://+:8080 + OTTHON_API_TOKEN: change-me-local-token + OTTHON_ADMIN_PASSWORD: change-me-admin + ConnectionStrings__Default: Server=mssql,1433;Database=JarvisHome;User Id=sa;Password=Jarvis!2026Api;TrustServerCertificate=True;Encrypt=False + ports: + - "5088:8080" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: jarvis-mssql + restart: unless-stopped + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: Jarvis!2026Api + ports: + - "14333:1433" + volumes: + - jarvis-mssql-data:/var/opt/mssql + +volumes: + jarvis-mssql-data: diff --git a/assets/branding/abdai-design-logo.png b/assets/branding/abdai-design-logo.png new file mode 100644 index 0000000..952b53c Binary files /dev/null and b/assets/branding/abdai-design-logo.png differ diff --git a/assets/branding/jarvis-api-logo-key.png b/assets/branding/jarvis-api-logo-key.png new file mode 100644 index 0000000..e544ae6 Binary files /dev/null and b/assets/branding/jarvis-api-logo-key.png differ diff --git a/assets/branding/jarvis-api-logo.png b/assets/branding/jarvis-api-logo.png new file mode 100644 index 0000000..358d139 Binary files /dev/null and b/assets/branding/jarvis-api-logo.png differ diff --git a/assets/kozos_ter_launcher.png b/assets/kozos_ter_launcher.png new file mode 100644 index 0000000..242360d Binary files /dev/null and b/assets/kozos_ter_launcher.png differ diff --git a/deploy/nginx/00-default-404.conf b/deploy/nginx/00-default-404.conf new file mode 100644 index 0000000..ac59b47 --- /dev/null +++ b/deploy/nginx/00-default-404.conf @@ -0,0 +1,17 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 404; +} + +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + + ssl_certificate /etc/letsencrypt/live/api-test.a-web.hu/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api-test.a-web.hu/privkey.pem; + + return 404; +} diff --git a/deploy/nginx/api-test.a-web.hu.conf b/deploy/nginx/api-test.a-web.hu.conf new file mode 100644 index 0000000..450875c --- /dev/null +++ b/deploy/nginx/api-test.a-web.hu.conf @@ -0,0 +1,18 @@ +server { + listen 80; + listen [::]:80; + server_name api-test.a-web.hu; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:5088; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/deploy/sql/clear-devices.sql b/deploy/sql/clear-devices.sql new file mode 100644 index 0000000..efa2627 --- /dev/null +++ b/deploy/sql/clear-devices.sql @@ -0,0 +1,3 @@ +USE JarvisHome; +DELETE FROM Devices; +SELECT COUNT(*) AS DeviceCount FROM Devices; diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c7b19c9 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..c47b8d6 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Otthon Naptar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + otthon_naptar + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app_shell.dart b/lib/app_shell.dart new file mode 100644 index 0000000..34b1903 --- /dev/null +++ b/lib/app_shell.dart @@ -0,0 +1,1007 @@ +part of 'main.dart'; + +class OtthonApp extends StatefulWidget { + const OtthonApp({super.key}); + + @override + State createState() => _OtthonAppState(); +} + +class _OtthonAppState extends State { + final store = AppJsonStore(); + final weatherService = WeatherService(); + List people = defaultPeople(); + List googleConnections = defaultGoogleConnections(); + List syncedEvents = []; + List todoSuggestions = []; + JarvisSettings jarvisSettings = JarvisSettings.defaults(); + WeatherSnapshot? weather; + String themeId = 'sage'; + bool loaded = false; + + AppThemeStyle get activeTheme => appThemes.firstWhere( + (theme) => theme.id == themeId, + orElse: () => appThemes.first, + ); + + @override + void initState() { + super.initState(); + _loadSettings(); + _loadWeather(); + } + + Future _loadSettings() async { + final settings = await store.load(); + if (!mounted) return; + setState(() { + people = settings.people; + googleConnections = settings.googleConnections; + syncedEvents = settings.syncedEvents; + todoSuggestions = settings.todoSuggestions; + jarvisSettings = settings.jarvisSettings; + themeId = settings.themeId; + loaded = true; + }); + } + + Future _saveSettings() async { + await store.save( + AppSettings( + people: people, + themeId: themeId, + googleConnections: googleConnections, + syncedEvents: syncedEvents, + todoSuggestions: todoSuggestions, + jarvisSettings: jarvisSettings, + ), + ); + } + + void _updatePeople(List nextPeople) { + setState(() => people = nextPeople); + _saveSettings(); + } + + void _updateTheme(String nextThemeId) { + setState(() => themeId = nextThemeId); + _saveSettings(); + } + + void _updateGoogleConnections( + List nextConnections, + ) { + setState(() => googleConnections = nextConnections); + _saveSettings(); + } + + void _updateSyncedEvents(List nextEvents) { + setState(() => syncedEvents = nextEvents); + _saveSettings(); + } + + void _updateTodoSuggestions(List nextSuggestions) { + setState(() => todoSuggestions = nextSuggestions); + _saveSettings(); + } + + void _updateJarvisSettings(JarvisSettings nextSettings) { + setState(() => jarvisSettings = nextSettings); + _saveSettings(); + } + + Future _loadWeather() async { + final snapshot = await weatherService.loadCurrentWeather(); + if (!mounted) return; + setState(() => weather = snapshot); + } + + @override + Widget build(BuildContext context) { + final theme = activeTheme; + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'K\u00f6z\u00f6sT\u00e9r', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: theme.seed, + surface: theme.surface, + ), + scaffoldBackgroundColor: theme.surface, + fontFamily: 'Arial', + cardTheme: const CardThemeData( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFFF1F3EF), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + home: HomeShell( + people: people, + googleConnections: googleConnections, + syncedEvents: syncedEvents, + todoSuggestions: todoSuggestions, + jarvisSettings: jarvisSettings, + weather: weather, + themeId: themeId, + loaded: loaded, + onPeopleChanged: _updatePeople, + onThemeChanged: _updateTheme, + onGoogleConnectionsChanged: _updateGoogleConnections, + onSyncedEventsChanged: _updateSyncedEvents, + onTodoSuggestionsChanged: _updateTodoSuggestions, + onJarvisSettingsChanged: _updateJarvisSettings, + ), + ); + } +} + +class HomeShell extends StatefulWidget { + const HomeShell({ + super.key, + required this.people, + required this.googleConnections, + required this.syncedEvents, + required this.todoSuggestions, + required this.jarvisSettings, + required this.weather, + required this.themeId, + required this.loaded, + required this.onPeopleChanged, + required this.onThemeChanged, + required this.onGoogleConnectionsChanged, + required this.onSyncedEventsChanged, + required this.onTodoSuggestionsChanged, + required this.onJarvisSettingsChanged, + }); + + final List people; + final List googleConnections; + final List syncedEvents; + final List todoSuggestions; + final JarvisSettings jarvisSettings; + final WeatherSnapshot? weather; + final String themeId; + final bool loaded; + final ValueChanged> onPeopleChanged; + final ValueChanged onThemeChanged; + final ValueChanged> onGoogleConnectionsChanged; + final ValueChanged> onSyncedEventsChanged; + final ValueChanged> onTodoSuggestionsChanged; + final ValueChanged onJarvisSettingsChanged; + + @override + State createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + int selected = 0; + bool jarvisBackendOnline = false; + Timer? jarvisBackendTimer; + + @override + void initState() { + super.initState(); + _checkJarvisBackend(); + jarvisBackendTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => _checkJarvisBackend(), + ); + } + + @override + void dispose() { + jarvisBackendTimer?.cancel(); + super.dispose(); + } + + final events = [ + CalendarEvent( + 'Csal\u00e1di reggeli', + '07:30', + '08:15', + const Color(0xFF79A894), + date: DateTime(2026, 7, 20), + ), + CalendarEvent( + 'Csapatmegbesz\u00e9l\u00e9s', + '10:00', + '11:00', + const Color(0xFF7189C8), + date: DateTime(2026, 7, 20), + place: 'Google Meet', + ), + CalendarEvent( + 'Fogorvos', + '16:30', + '17:15', + const Color(0xFFE89552), + date: DateTime(2026, 7, 21), + place: 'R\u00f3zsadomb', + ), + CalendarEvent( + 'Farsang', + '10:00', + '12:00', + const Color(0xFF7189C8), + date: DateTime(2026, 7, 22), + personId: 'balint', + ), + CalendarEvent( + 'Vacsora Ann\u00e1\u00e9kkal', + '19:00', + '21:30', + const Color(0xFF9B78B4), + date: DateTime(2026, 7, 24), + place: 'Kert Bisztr\u00f3', + ), + CalendarEvent( + 'Piac', + '09:00', + '10:30', + const Color(0xFF79A894), + date: DateTime(2026, 7, 25), + ), + CalendarEvent( + 'Ballag\u00e1s', + '09:00', + '11:00', + const Color(0xFFE89552), + date: DateTime(2026, 8, 1), + personId: 'emilia', + ), + ]; + + final shopping = [ + ShoppingItem('Zabtej', '2 doboz', 'Tejterm\u00e9k'), + ShoppingItem('Paradicsom', '1 kg', 'Z\u00f6lds\u00e9g'), + ShoppingItem('Kov\u00e1szos keny\u00e9r', '1 db', 'P\u00e9k\u00e1ru'), + ShoppingItem( + 'Mosogat\u00f3g\u00e9p tabletta', + '1 csomag', + 'H\u00e1ztart\u00e1s', + ), + ShoppingItem('Ban\u00e1n', '6 db', 'Gy\u00fcm\u00f6lcs', done: true), + ]; + + final stock = [ + StockItem('Toj\u00e1s', 8, 'db', 'H\u0171t\u0151', 'j\u00fal. 24.'), + StockItem( + 'G\u00f6r\u00f6g joghurt', + 2, + 'db', + 'H\u0171t\u0151', + 'j\u00fal. 22.', + ), + StockItem('Vaj', 1, 'csomag', 'H\u0171t\u0151', 'j\u00fal. 29.'), + StockItem('Rizs', 2, 'kg', 'Kamra', '2027. m\u00e1rc.'), + StockItem('T\u00e9szta', 3, 'csomag', 'Kamra', '2027. jan.'), + StockItem('K\u00e1v\u00e9', 1, 'csomag', 'Kamra', '2026. nov.'), + ]; + + List get allEvents => [...events, ...widget.syncedEvents]; + + final labels = const [ + 'Napt\u00e1r', + 'Bev\u00e1s\u00e1rl\u00e1s', + 'K\u00e9szlet', + 'Csal\u00e1dtagok', + 'St\u00edlus', + 'Fi\u00f3kok', + ]; + + final icons = const [ + Icons.calendar_month_rounded, + Icons.shopping_basket_outlined, + Icons.kitchen_outlined, + Icons.group_outlined, + Icons.palette_outlined, + Icons.sync_rounded, + ]; + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 850; + final body = IndexedStack( + index: selected, + children: [ + CalendarPage( + events: allEvents, + people: widget.people, + weather: widget.weather, + onAdd: _addEvent, + onEdit: _editEvent, + onAssignPerson: _assignSyncedEventToPerson, + ), + ShoppingPage( + items: shopping, + onChanged: () => setState(() {}), + onAdd: _addShopping, + ), + StockPage( + items: stock, + onChanged: () => setState(() {}), + onAdd: _addStock, + onEdit: _editStock, + jarvisSettings: widget.jarvisSettings, + ), + PersonsPage( + people: widget.people, + events: allEvents, + onAdd: _addPerson, + onEdit: _editPerson, + onDelete: _deletePerson, + ), + StylePage( + themeId: widget.themeId, + onThemeChanged: widget.onThemeChanged, + ), + AccountsPage( + connections: widget.googleConnections, + cachedEvents: widget.syncedEvents, + people: widget.people, + onChanged: widget.onGoogleConnectionsChanged, + onSyncedEventsChanged: widget.onSyncedEventsChanged, + ), + ], + ); + + return Scaffold( + body: SafeArea( + child: Row( + children: [ + if (wide) + SizedBox( + width: 220, + child: Stack( + children: [ + NavigationRail( + backgroundColor: Colors.white, + extended: true, + minExtendedWidth: 220, + selectedIndex: selected, + onDestinationSelected: (i) => + setState(() => selected = i), + leading: Padding( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 28), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF6F4EF), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: sage.withValues(alpha: .10), + ), + boxShadow: [ + BoxShadow( + color: sage.withValues(alpha: .08), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: sage, + borderRadius: BorderRadius.circular(15), + ), + clipBehavior: Clip.antiAlias, + child: const Icon( + Icons.dashboard_customize_outlined, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'K\u00f6z\u00f6sT\u00e9r', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: ink, + letterSpacing: .2, + ), + ), + Text( + widget.loaded + ? 'Tervez\u0151' + : 'JSON bet\u00f6lt\u00e9se\u2026', + style: const TextStyle( + fontSize: 11, + color: Colors.black45, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + const Text( + appVersionLabel, + style: TextStyle( + fontSize: 10, + color: Colors.black38, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'JARVIS be\u00e1ll\u00edt\u00e1sok', + onPressed: _openJarvisSettings, + icon: const Icon( + Icons.settings_outlined, + size: 20, + ), + style: IconButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: sage, + minimumSize: const Size(36, 36), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + destinations: List.generate( + labels.length, + (i) => NavigationRailDestination( + icon: Icon(icons[i]), + selectedIcon: Icon(icons[i]), + label: Text(labels[i]), + ), + ), + ), + Positioned( + left: 16, + bottom: 12, + right: 16, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .92), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Colors.white.withValues(alpha: .08), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: .08), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: 27, + height: 27, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .08), + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: Image.asset( + 'assets/branding/jarvis-api-logo.png', + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'JARVIS Backend', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 11, + height: 11, + decoration: BoxDecoration( + color: jarvisBackendOnline + ? Colors.greenAccent + : Colors.redAccent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: + (jarvisBackendOnline + ? Colors.greenAccent + : Colors.redAccent) + .withValues(alpha: .45), + blurRadius: 8, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + jarvisBackendOnline ? 'online' : 'offline', + style: TextStyle( + color: jarvisBackendOnline + ? Colors.greenAccent + : Colors.redAccent, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Center( + child: Opacity( + opacity: .78, + child: Image.asset( + 'assets/branding/abdai-design-logo.png', + fit: BoxFit.contain, + alignment: Alignment.center, + height: 28, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + Expanded(child: body), + ], + ), + ), + bottomNavigationBar: wide + ? null + : NavigationBar( + selectedIndex: selected, + onDestinationSelected: (i) => setState(() => selected = i), + destinations: List.generate( + labels.length, + (i) => NavigationDestination( + icon: Icon(icons[i]), + label: labels[i], + ), + ), + ), + ); + } + + Future _checkJarvisBackend() async { + final online = await _isJarvisBackendOnline(); + if (!mounted || jarvisBackendOnline == online) return; + setState(() => jarvisBackendOnline = online); + } + + Future _isJarvisBackendOnline() async { + try { + final baseUri = Uri.parse( + normalizeJarvisUrl(widget.jarvisSettings.apiUrl), + ); + final response = await http + .get(baseUri.resolve('/health')) + .timeout(const Duration(seconds: 3)); + return response.statusCode >= 200 && response.statusCode < 300; + } catch (_) { + return false; + } + } + + Future _openJarvisSettings() async { + final result = await showDialog( + context: context, + builder: (_) => JarvisSettingsDialog( + settings: widget.jarvisSettings, + onRegister: _registerJarvisDevice, + onRefreshStatus: _refreshJarvisDeviceStatus, + ), + ); + if (result == null) return; + widget.onJarvisSettingsChanged(result); + await Future.delayed(Duration.zero); + _checkJarvisBackend(); + } + + Future _registerJarvisDevice( + JarvisSettings settings, + String deviceName, + ) async { + final baseUri = Uri.parse(normalizeJarvisUrl(settings.apiUrl)); + final trimmedDeviceName = deviceName.trim().isEmpty + ? 'Family tablet' + : deviceName.trim(); + final response = await http + .post( + baseUri.resolve('/api/devices/register'), + headers: { + 'Content-Type': 'application/json', + 'X-Otthon-Token': settings.apiToken, + }, + body: jsonEncode({ + 'deviceName': trimmedDeviceName, + 'platform': Platform.operatingSystem, + }), + ) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('JARVIS registration failed: ${response.statusCode}'); + } + + final payload = jsonDecode(response.body); + if (payload is! Map) { + throw Exception('Invalid JARVIS response.'); + } + + return settings.copyWith( + deviceName: trimmedDeviceName, + deviceId: payload['id'] as int?, + deviceToken: payload['deviceToken'] as String?, + registrationStatus: payload['status'] as String? ?? 'pending', + ); + } + + Future _refreshJarvisDeviceStatus( + JarvisSettings settings, + ) async { + final deviceId = settings.deviceId; + if (deviceId == null) { + throw Exception('Nincs regisztr\u00e1lt eszk\u00f6z.'); + } + + final baseUri = Uri.parse(normalizeJarvisUrl(settings.apiUrl)); + final response = await http + .get( + baseUri.resolve('/api/devices/$deviceId/status'), + headers: {'X-Otthon-Token': settings.apiToken}, + ) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('JARVIS status failed: ${response.statusCode}'); + } + + final payload = jsonDecode(response.body); + if (payload is! Map) { + throw Exception('Invalid JARVIS response.'); + } + + if (payload['found'] == false || + payload['status'] == 'notRegistered' || + payload['status'] == 'notFound') { + return settings.copyWith(clearDevice: true); + } + + return settings.copyWith( + registrationStatus: payload['status'] as String? ?? 'pending', + ); + } + + Future _addEvent() async { + final data = await showDialog( + context: context, + builder: (_) => EventAddDialog( + people: widget.people, + todoSuggestions: widget.todoSuggestions, + ), + ); + if (data != null && data.title.trim().isNotEmpty) { + final assignedPersonId = data.personId ?? everyonePersonId; + setState( + () => events.add( + CalendarEvent( + data.title.trim(), + data.start.isEmpty ? '12:00' : data.start, + '13:00', + assignedPersonId == everyonePersonId + ? sage + : widget.people + .firstWhere((p) => p.id == assignedPersonId) + .color, + personId: assignedPersonId, + date: data.date, + note: data.note, + hasTodos: data.hasTodos, + todos: data.todos, + ), + ), + ); + _rememberTodos(data.todos); + } + } + + Future _editEvent(CalendarEvent event) async { + final data = await showDialog( + context: context, + builder: (_) => EventAddDialog( + people: widget.people, + event: event, + todoSuggestions: widget.todoSuggestions, + ), + ); + if (data == null || data.title.trim().isEmpty) return; + + final assignedPersonId = data.personId ?? everyonePersonId; + final color = assignedPersonId == everyonePersonId + ? sage + : widget.people.firstWhere((p) => p.id == assignedPersonId).color; + final updated = event.copyWith( + title: data.title.trim(), + start: data.start.isEmpty ? '12:00' : data.start, + date: data.date, + color: color, + personId: assignedPersonId, + note: data.note, + hasTodos: data.hasTodos, + todos: data.todos, + ); + + setState(() { + for (var i = 0; i < events.length; i++) { + if (identical(events[i], event)) { + events[i] = updated; + return; + } + } + }); + + if (event.isSynced) { + final nextSyncedEvents = [ + for (final syncedEvent in widget.syncedEvents) + if (identical(syncedEvent, event)) updated else syncedEvent, + ]; + widget.onSyncedEventsChanged(nextSyncedEvents); + } + _rememberTodos(data.todos); + } + + void _rememberTodos(List todos) { + final nextSuggestions = [ + ...widget.todoSuggestions, + ...todos.where((todo) => todo.trim().isNotEmpty), + ].map((todo) => todo.trim()).toSet().toList()..sort(); + widget.onTodoSuggestionsChanged(nextSuggestions); + } + + Future _addPerson() async { + final draft = await showDialog( + context: context, + builder: (_) => PersonDialog( + title: '\u00daj csal\u00e1dtag', + initialColor: personColors[widget.people.length % personColors.length], + initialAvatar: + avatarPresets[widget.people.length % avatarPresets.length].symbol, + ), + ); + if (draft == null) return; + + widget.onPeopleChanged([ + ...widget.people, + Person( + id: uniqueId(), + name: draft.name, + colorValue: draft.color.toARGB32(), + avatar: draft.avatar, + nextEventLimit: draft.nextEventLimit, + ), + ]); + } + + Future _editPerson(Person person) async { + final draft = await showDialog( + context: context, + builder: (_) => PersonDialog( + title: '${person.name} szerkeszt\u00e9se', + initialName: person.name, + initialColor: person.color, + initialAvatar: person.avatar, + initialNextEventLimit: person.nextEventLimit, + ), + ); + if (draft == null) return; + + widget.onPeopleChanged( + widget.people + .map( + (item) => item.id == person.id + ? item.copyWith( + name: draft.name, + colorValue: draft.color.toARGB32(), + avatar: draft.avatar, + nextEventLimit: draft.nextEventLimit, + ) + : item, + ) + .toList(), + ); + } + + Future _deletePerson(Person person) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('${person.name} t\u00f6rl\u00e9se?'), + content: const Text( + 'A csal\u00e1dtag t\u00f6rl\u0151dik a JSON-b\u0151l. A hozz\u00e1 k\u00f6t\u00f6tt esem\u00e9nyek megmaradnak, de "Mindenki" esem\u00e9nny\u00e9 v\u00e1lnak.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('T\u00f6rl\u00e9s'), + ), + ], + ), + ); + if (confirmed != true) return; + + setState(() { + for (var i = 0; i < events.length; i++) { + if (events[i].personId == person.id) { + events[i] = events[i].copyWith(clearPerson: true); + } + } + }); + widget.onPeopleChanged( + widget.people.where((item) => item.id != person.id).toList(), + ); + } + + void _assignSyncedEventToPerson(CalendarEvent event, Person person) { + final nextSyncedEvents = widget.syncedEvents + .map( + (item) => sameImportedEvent(item, event) + ? item.copyWith(personId: person.id) + : item, + ) + .toList(); + widget.onSyncedEventsChanged(nextSyncedEvents); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${event.title} hozz\u00e1rendelve: ${person.name}'), + ), + ); + } + + Future _addShopping() async { + final data = await showDialog>( + context: context, + builder: (_) => const QuickAddDialog( + title: 'T\u00e9tel hozz\u00e1ad\u00e1sa', + firstLabel: 'Mit vegy\u00fcnk?', + secondLabel: 'Mennyis\u00e9g', + ), + ); + if (data != null && data[0].trim().isNotEmpty) { + setState( + () => shopping.add( + ShoppingItem( + data[0], + data[1].isEmpty ? '1 db' : data[1], + 'Egy\u00e9b', + ), + ), + ); + } + } + + Future _addStock() async { + final data = await showDialog( + context: context, + builder: (_) => StockAddDialog(jarvisSettings: widget.jarvisSettings), + ); + if (data != null && data.name.trim().isNotEmpty) { + setState( + () => stock.add( + StockItem( + data.name, + data.amount, + data.unit, + data.location, + data.expiry, + ), + ), + ); + unawaited(_sendFoodLearningEvent(data, action: 'stock-added')); + } + } + + Future _editStock(StockItem item) async { + final data = await showDialog( + context: context, + builder: (_) => StockAddDialog( + jarvisSettings: widget.jarvisSettings, + initialItem: item, + ), + ); + if (data == null || data.name.trim().isEmpty) return; + + setState(() { + item.name = data.name.trim(); + item.amount = data.amount; + item.unit = data.unit.trim().isEmpty ? 'db' : data.unit.trim(); + item.location = data.location; + item.expiry = data.expiry.trim().isEmpty + ? 'nincs megadva' + : data.expiry.trim(); + }); + unawaited(_sendFoodLearningEvent(data, action: 'stock-updated')); + } + + Future _sendFoodLearningEvent( + StockInput input, { + required String action, + }) async { + final apiUrl = widget.jarvisSettings.apiUrl.trim(); + final apiToken = widget.jarvisSettings.apiToken.trim(); + if (apiUrl.isEmpty || apiToken.isEmpty) return; + + try { + final baseUri = Uri.parse(normalizeJarvisUrl(apiUrl)); + await http + .post( + baseUri.resolve('/api/food-events'), + headers: { + 'Content-Type': 'application/json', + 'X-Otthon-Token': apiToken, + }, + body: jsonEncode({ + 'productName': input.name.trim(), + 'barcode': input.barcode.trim().isEmpty + ? null + : input.barcode.trim(), + 'action': action, + 'amount': input.amount, + 'unit': input.unit, + 'location': input.location, + 'localTime': DateTime.now().toIso8601String(), + }), + ) + .timeout(const Duration(seconds: 8)); + } catch (_) { + // A tanul\u00e1s kieg\u00e9sz\u00edt\u0151 funkci\u00f3: offline/hiba eset\u00e9n a k\u00e9szlet ment\u00e9se nem akad meg. + } + } +} diff --git a/lib/calendar_widgets.dart b/lib/calendar_widgets.dart new file mode 100644 index 0000000..cf6ba4c --- /dev/null +++ b/lib/calendar_widgets.dart @@ -0,0 +1,1503 @@ +part of 'main.dart'; + +class PageFrame extends StatelessWidget { + const PageFrame({ + super.key, + required this.eyebrow, + required this.title, + required this.child, + this.action, + }); + + final String eyebrow; + final String title; + final Widget child; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 14, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.end, + alignment: WrapAlignment.spaceBetween, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 220, maxWidth: 620), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow.toUpperCase(), + style: const TextStyle( + color: sage, + fontSize: 12, + letterSpacing: 1.6, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 5), + Text( + title, + style: const TextStyle( + color: ink, + fontSize: 30, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ?action, + ], + ), + const SizedBox(height: 24), + Expanded(child: child), + ], + ), + ); + } +} + +class CalendarPage extends StatefulWidget { + const CalendarPage({ + super.key, + required this.events, + required this.people, + required this.weather, + required this.onAdd, + required this.onEdit, + this.onAssignPerson, + }); + + final List events; + final List people; + final WeatherSnapshot? weather; + final VoidCallback onAdd; + final ValueChanged onEdit; + final void Function(CalendarEvent event, Person person)? onAssignPerson; + + @override + State createState() => _CalendarPageState(); +} + +class _CalendarPageState extends State { + int selectedDay = DateTime.now().weekday - 1; + DateTime weekStart = startOfIsoWeek(DateTime.now()); + Timer? returnToTodayTimer; + static const days = ['H', 'K', 'Sze', 'Cs', 'P', 'Szo', 'V']; + + @override + void dispose() { + returnToTodayTimer?.cancel(); + super.dispose(); + } + + void _scheduleReturnToToday() { + returnToTodayTimer?.cancel(); + returnToTodayTimer = Timer(const Duration(seconds: 10), _returnToToday); + } + + void _returnToToday() { + final now = DateTime.now(); + final currentWeekStart = startOfIsoWeek(now); + final currentDay = now.weekday - 1; + if (!mounted) return; + setState(() { + weekStart = currentWeekStart; + selectedDay = currentDay; + }); + } + + void _selectDay(int day) { + setState(() => selectedDay = day); + _scheduleReturnToToday(); + } + + void _moveWeek(int weekDelta) { + setState(() { + weekStart = weekStart.add(Duration(days: weekDelta * 7)); + selectedDay = 0; + }); + _scheduleReturnToToday(); + } + + @override + Widget build(BuildContext context) { + final selectedDate = weekStart.add(Duration(days: selectedDay)); + final todayEvents = + widget.events + .where((event) => isSameDate(event.date, selectedDate)) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + return PageFrame( + eyebrow: + '${formatDate(weekStart)} \u2013 ${formatDate(weekStart.add(const Duration(days: 6)))}', + title: '${isoWeekNumber(weekStart)}. h\u00e9t', + action: FilledButton.icon( + onPressed: widget.onAdd, + icon: const Icon(Icons.add), + label: const Text('\u00daj esem\u00e9ny'), + ), + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + LayoutBuilder( + builder: (context, constraints) { + final heroPanel = CalendarHeroCard( + selectedDate: selectedDate, + eventCount: todayEvents.length, + events: todayEvents, + people: widget.people, + weather: widget.weather, + ); + + final peoplePanel = NextPersonalEventsTile( + events: widget.events, + people: widget.people, + onEdit: widget.onEdit, + ); + + if (constraints.maxWidth >= 980) { + return Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 5, child: heroPanel), + const SizedBox(width: 14), + Expanded(flex: 5, child: peoplePanel), + ], + ), + const SizedBox(height: 16), + WeekBoard( + events: widget.events, + people: widget.people, + weekStart: weekStart, + selectedDay: selectedDay, + onSelectDay: _selectDay, + onPreviousWeek: () => _moveWeek(-1), + onNextWeek: () => _moveWeek(1), + ), + const SizedBox(height: 16), + DayDetailsPanel( + selectedDayLabel: days[selectedDay], + selectedDate: selectedDate, + events: todayEvents, + people: widget.people, + onEdit: widget.onEdit, + onAssignPerson: widget.onAssignPerson, + allEvents: widget.events, + ), + ], + ); + } + + return Column( + children: [ + heroPanel, + const SizedBox(height: 14), + peoplePanel, + const SizedBox(height: 14), + WeekBoard( + events: widget.events, + people: widget.people, + weekStart: weekStart, + selectedDay: selectedDay, + onSelectDay: _selectDay, + onPreviousWeek: () => _moveWeek(-1), + onNextWeek: () => _moveWeek(1), + ), + const SizedBox(height: 14), + DayDetailsPanel( + selectedDayLabel: days[selectedDay], + selectedDate: selectedDate, + events: todayEvents, + people: widget.people, + onEdit: widget.onEdit, + onAssignPerson: widget.onAssignPerson, + allEvents: widget.events, + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +CalendarEvent? findNextEventForPerson( + String personId, + List events, { + DateTime? now, +}) { + final personal = findNextEventsForPerson(personId, events, 1, now: now); + return personal.isEmpty ? null : personal.first; +} + +List findNextEventsForPerson( + String personId, + List events, + int limit, { + DateTime? now, +}) { + final today = startOfDay(now ?? DateTime.now()); + final personal = + events + .where( + (e) => e.personId == personId || e.personId == everyonePersonId, + ) + .where((e) => !startOfDay(e.date).isBefore(today)) + .toList() + ..sort((a, b) { + final dateCompare = a.date.compareTo(b.date); + return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start); + }); + return personal.take(limit).toList(); +} + +Person? personForEvent(CalendarEvent event, List people) { + final personId = event.personId; + if (personId == null) return null; + for (final person in people) { + if (person.id == personId) return person; + } + return null; +} + +bool sameImportedEvent(CalendarEvent a, CalendarEvent b) { + if (a.externalId != null && b.externalId != null) { + return a.externalId == b.externalId && a.calendarId == b.calendarId; + } + return a.title == b.title && + a.calendarId == b.calendarId && + isSameDate(a.date, b.date) && + a.start == b.start; +} + +class CalendarHeroCard extends StatelessWidget { + const CalendarHeroCard({ + super.key, + required this.selectedDate, + required this.eventCount, + required this.events, + required this.people, + required this.weather, + }); + + final DateTime selectedDate; + final int eventCount; + final List events; + final List people; + final WeatherSnapshot? weather; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF18332D), Color(0xFF2F6B5B), Color(0xFFE89552)], + stops: [.05, .66, 1], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: sage.withValues(alpha: .24), + blurRadius: 30, + offset: const Offset(0, 18), + ), + ], + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ma a csal\u00e1dban', + style: TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + formatDate(selectedDate), + style: const TextStyle( + color: Colors.white, + fontSize: 25, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 7), + Text( + eventCount == 0 + ? 'Szell\u0151s nap. Ilyen is kell.' + : '$eventCount program v\u00e1rhat\u00f3 ezen a napon.', + style: const TextStyle(color: Colors.white, height: 1.35), + ), + if (events.isNotEmpty) ...[ + const SizedBox(height: 8), + ...events.take(3).map((event) { + final person = personForEvent(event, people); + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + MiniAudienceAvatar(event: event, person: person), + const SizedBox(width: 8), + Expanded( + child: Text( + '${event.start} \u00b7 ${event.title}' + '${person == null ? '' : ' \u00b7 ${person.name}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + }), + ], + ], + ), + ), + const SizedBox(width: 14), + Expanded( + flex: 2, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + const LiveClockChip(), + HeroStatChip( + icon: weather?.icon ?? Icons.thermostat_outlined, + label: weather?.temperatureLabel ?? '\u2014', + subtitle: weather == null + ? 'id\u0151j\u00e1r\u00e1s' + : '${weather!.locationName} \u00b7 ${weather!.description}', + ), + ], + ), + ), + ], + ), + ); + } +} + +class DayDetailsPanel extends StatelessWidget { + const DayDetailsPanel({ + super.key, + required this.selectedDayLabel, + required this.selectedDate, + required this.events, + required this.people, + required this.onEdit, + required this.onAssignPerson, + required this.allEvents, + }); + + final String selectedDayLabel; + final DateTime selectedDate; + final List events; + final List people; + final ValueChanged onEdit; + final void Function(CalendarEvent event, Person person)? onAssignPerson; + final List allEvents; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFE8E5DD)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedDayLabel, ${formatDate(selectedDate)}', + style: const TextStyle( + color: ink, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 10), + if (events.isEmpty) + const SizedBox( + height: 124, + child: EmptyState( + icon: Icons.wb_sunny_outlined, + title: 'Szabad nap', + subtitle: 'Ezen a napon m\u00e9g nincs programod.', + ), + ) + else + ...events.map( + (event) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: EventCard( + event: event, + person: personForEvent(event, people), + onTap: () => onEdit(event), + ), + ), + ), + UncategorizedEventsTile( + events: allEvents, + people: people, + onAssignPerson: onAssignPerson, + ), + ], + ), + ); + } +} + +class LiveClockChip extends StatefulWidget { + const LiveClockChip({super.key}); + + @override + State createState() => _LiveClockChipState(); +} + +class _LiveClockChipState extends State { + late DateTime now; + Timer? timer; + + @override + void initState() { + super.initState(); + now = DateTime.now(); + timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => now = DateTime.now()); + }); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 128, + constraints: const BoxConstraints(minHeight: 114), + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .18), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white30), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 50, + height: 50, + child: CustomPaint(painter: _ClockPainter(now)), + ), + const SizedBox(height: 6), + Text( + formatTimeWithSeconds(now), + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w900, + letterSpacing: .4, + ), + ), + const SizedBox(height: 2), + const Text( + '\u00e9l\u0151 \u00f3ra', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ); + } +} + +class _ClockPainter extends CustomPainter { + _ClockPainter(this.time); + + final DateTime time; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.shortestSide / 2; + final facePaint = Paint() + ..color = Colors.white.withValues(alpha: .16) + ..style = PaintingStyle.fill; + final rimPaint = Paint() + ..color = Colors.white.withValues(alpha: .7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + canvas.drawCircle(center, radius - 1, facePaint); + canvas.drawCircle(center, radius - 1, rimPaint); + + final tickPaint = Paint() + ..color = Colors.white.withValues(alpha: .75) + ..strokeCap = StrokeCap.round + ..strokeWidth = 1.4; + for (var i = 0; i < 12; i++) { + final angle = math.pi * 2 * i / 12 - math.pi / 2; + final outer = + center + Offset(math.cos(angle), math.sin(angle)) * (radius - 7); + final inner = + center + Offset(math.cos(angle), math.sin(angle)) * (radius - 12); + canvas.drawLine(inner, outer, tickPaint); + } + + void hand(double angle, double length, double width, Color color) { + final paint = Paint() + ..color = color + ..strokeWidth = width + ..strokeCap = StrokeCap.round; + final end = center + Offset(math.cos(angle), math.sin(angle)) * length; + canvas.drawLine(center, end, paint); + } + + final secondAngle = math.pi * 2 * time.second / 60 - math.pi / 2; + final minuteAngle = + math.pi * 2 * (time.minute + time.second / 60) / 60 - math.pi / 2; + final hourAngle = + math.pi * 2 * ((time.hour % 12) + time.minute / 60) / 12 - math.pi / 2; + + hand(hourAngle, radius * .45, 4, Colors.white); + hand(minuteAngle, radius * .62, 3, Colors.white.withValues(alpha: .9)); + hand(secondAngle, radius * .68, 1.4, const Color(0xFFFFD08A)); + + canvas.drawCircle(center, 4, Paint()..color = const Color(0xFFFFD08A)); + } + + @override + bool shouldRepaint(covariant _ClockPainter oldDelegate) => + oldDelegate.time.second != time.second; +} + +class HeroStatChip extends StatelessWidget { + const HeroStatChip({ + super.key, + required this.icon, + required this.label, + required this.subtitle, + }); + + final IconData icon; + final String label; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Container( + width: 128, + constraints: const BoxConstraints(minHeight: 114), + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .15), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white24), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, color: Colors.white, size: 25), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ); + } +} + +class WeekBoard extends StatelessWidget { + const WeekBoard({ + super.key, + required this.events, + required this.people, + required this.weekStart, + required this.selectedDay, + required this.onSelectDay, + required this.onPreviousWeek, + required this.onNextWeek, + }); + + final List events; + final List people; + final DateTime weekStart; + final int selectedDay; + final ValueChanged onSelectDay; + final VoidCallback onPreviousWeek; + final VoidCallback onNextWeek; + + static const days = [ + 'H\u00e9tf\u0151', + 'Kedd', + 'Szerda', + 'Cs\u00fct\u00f6rt\u00f6k', + 'P\u00e9ntek', + 'Szombat', + 'Vas\u00e1rnap', + ]; + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.view_week_outlined, color: sage), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Heti csal\u00e1di napt\u00e1r', + style: TextStyle( + color: ink, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton( + tooltip: 'El\u0151z\u0151 h\u00e9t', + onPressed: onPreviousWeek, + icon: const Icon(Icons.chevron_left), + ), + Text( + '${isoWeekNumber(weekStart)}. h\u00e9t', + style: const TextStyle( + color: sage, + fontWeight: FontWeight.w900, + ), + ), + IconButton( + tooltip: 'K\u00f6vetkez\u0151 h\u00e9t', + onPressed: onNextWeek, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(7, (index) { + final date = weekStart.add(Duration(days: index)); + final dayEvents = + events + .where((event) => isSameDate(event.date, date)) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: index == 6 ? 0 : 6), + child: WeekDayColumn( + title: days[index], + dateLabel: formatShortDate(date), + active: selectedDay == index, + events: dayEvents, + people: people, + onTap: () => onSelectDay(index), + ), + ), + ); + }), + ), + ], + ), + ), + ); + } +} + +class WeekDayColumn extends StatelessWidget { + const WeekDayColumn({ + super.key, + required this.title, + required this.dateLabel, + required this.active, + required this.events, + required this.people, + required this.onTap, + }); + + final String title; + final String dateLabel; + final bool active; + final List events; + final List people; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: double.infinity, + constraints: const BoxConstraints(minHeight: 190), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: active ? mint : const Color(0xFFF8F8F5), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: active ? sage : const Color(0xFFE8E5DD), + width: active ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: ink, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 7), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? sage : Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: active + ? [ + BoxShadow( + color: sage.withValues(alpha: .18), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ] + : null, + ), + child: Text( + dateLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: TextStyle( + color: active ? Colors.white : sage, + fontSize: 17, + letterSpacing: .5, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(height: 10), + if (events.isEmpty) + const SizedBox( + height: 92, + child: Center( + child: Text( + 'nincs program', + style: TextStyle( + color: Colors.black38, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ) + else + ...events + .take(4) + .map( + (event) => WeekEventPill( + event: event, + person: personForEvent(event, people), + ), + ), + if (events.length > 4) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '+${events.length - 4} tov\u00e1bbi', + style: const TextStyle( + color: Colors.black54, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ); + } +} + +class WeekEventPill extends StatelessWidget { + const WeekEventPill({super.key, required this.event, required this.person}); + + final CalendarEvent event; + final Person? person; + + @override + Widget build(BuildContext context) { + final color = person?.color ?? (event.isSynced ? orange : sage); + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border(left: BorderSide(color: color, width: 3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MiniAudienceAvatar(event: event, person: person), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.start, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 1), + Text( + event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + fontSize: 11.5, + height: 1.15, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class MiniAudienceAvatar extends StatelessWidget { + const MiniAudienceAvatar({ + super.key, + required this.event, + required this.person, + }); + + final CalendarEvent event; + final Person? person; + + @override + Widget build(BuildContext context) { + if (person != null) { + return FamilyAvatar(person: person!, radius: 13); + } + final icon = event.isSynced + ? Icons.inbox_outlined + : Icons.groups_2_outlined; + final color = event.isSynced ? orange : sage; + return CircleAvatar( + radius: 13, + backgroundColor: color.withValues(alpha: .14), + child: Icon(icon, color: color, size: 15), + ); + } +} + +List googleImportedEvents( + List connections, +) { + final enabledIds = connections + .expand((connection) => connection.enabledCalendarIds) + .toSet(); + + final imported = []; + if (enabledIds.contains('primary')) { + imported.add( + CalendarEvent( + 'Google: orvosi kontroll', + '14:00', + '14:45', + const Color(0xFF4285F4), + date: DateTime(2026, 7, 23), + place: 'Google Calendar', + ), + ); + } + if (enabledIds.contains('family')) { + imported.add( + CalendarEvent( + 'Google: k\u00f6z\u00f6s csal\u00e1di eb\u00e9d', + '12:30', + '14:00', + const Color(0xFF34A853), + date: DateTime(2026, 7, 26), + place: 'Csal\u00e1di napt\u00e1r', + ), + ); + } + if (enabledIds.contains('school')) { + imported.add( + CalendarEvent( + 'Google: ovis kir\u00e1ndul\u00e1s', + '08:30', + '12:00', + const Color(0xFFFBBC05), + date: DateTime(2026, 7, 24), + place: 'Ovi / iskola', + ), + ); + } + if (enabledIds.contains('work')) { + imported.add( + CalendarEvent( + 'Google: projekt st\u00e1tusz', + '15:00', + '15:30', + const Color(0xFF9B78B4), + date: DateTime(2026, 7, 22), + place: 'Munka', + ), + ); + } + return imported; +} + +class NextPersonalEventsTile extends StatelessWidget { + const NextPersonalEventsTile({ + super.key, + required this.events, + required this.people, + required this.onEdit, + }); + + final List events; + final List people; + final ValueChanged onEdit; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: mint, + borderRadius: BorderRadius.circular(24), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.event_available_outlined, color: sage, size: 24), + SizedBox(width: 10), + Expanded( + child: Text( + 'Csal\u00e1dtagok k\u00f6vetkez\u0151 esem\u00e9nyei', + style: TextStyle( + color: ink, + fontWeight: FontWeight.w900, + fontSize: 19, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + if (people.isEmpty) + const Text( + 'M\u00e9g nincs felvett csal\u00e1dtag.', + style: TextStyle(color: Colors.black54), + ) + else + LayoutBuilder( + builder: (context, constraints) { + if (people.length == 4 && constraints.maxWidth >= 420) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 2.05, + ), + itemCount: people.length, + itemBuilder: (_, index) => _personCard(people[index]), + ); + } + + return Column( + children: [ + ...people.map( + (person) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _personCard(person), + ), + ), + ], + ); + }, + ), + ], + ), + ); + } + + Widget _personCard(Person person) { + final nextEvents = findNextEventsForPerson( + person.id, + events, + person.nextEventLimit, + ); + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FamilyAvatar(person: person, radius: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + person.name, + style: const TextStyle( + color: ink, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + Text( + '${person.nextEventLimit} db', + style: const TextStyle( + color: Colors.black45, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 7), + if (nextEvents.isEmpty) + const Text( + 'nincs', + style: TextStyle( + color: Colors.black45, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ) + else + ...nextEvents.map( + (event) => InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => onEdit(event), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Expanded( + flex: 5, + child: Text( + event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: ink, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 82, + child: Text( + '${formatDate(event.date)}\n${event.start}', + textAlign: TextAlign.right, + style: const TextStyle( + color: sage, + fontSize: 12, + fontWeight: FontWeight.w900, + height: 1.2, + ), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.edit_outlined, + size: 15, + color: Colors.black38, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class UncategorizedEventsTile extends StatelessWidget { + const UncategorizedEventsTile({ + super.key, + required this.events, + required this.people, + required this.onAssignPerson, + }); + + final List events; + final List people; + final void Function(CalendarEvent event, Person person)? onAssignPerson; + + @override + Widget build(BuildContext context) { + final uncategorized = + events + .where((event) => event.isSynced && event.isUncategorized) + .toList() + ..sort((a, b) { + final dateCompare = a.date.compareTo(b.date); + return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start); + }); + + if (uncategorized.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: 14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFF4DF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFFFD99A)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.inbox_outlined, color: orange), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Kategoriz\u00e1latlan esem\u00e9nyek (${uncategorized.length})', + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Koppints egy esem\u00e9nyre, \u00e9s rendeld csal\u00e1dtaghoz.', + style: TextStyle(color: Colors.black54), + ), + const SizedBox(height: 8), + ...uncategorized.map( + (event) => Card( + margin: const EdgeInsets.only(top: 8), + color: Colors.white, + child: ListTile( + leading: CircleAvatar( + backgroundColor: orange.withValues(alpha: .14), + child: const Icon(Icons.inbox_outlined, color: orange), + ), + title: Text( + event.title, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + ), + ), + subtitle: Text( + '${formatDate(event.date)} ${event.start}' + '${event.calendarName == null ? '' : ' \u00b7 ${event.calendarName}'}', + ), + trailing: const Icon(Icons.person_add_alt_1_outlined), + onTap: people.isEmpty || onAssignPerson == null + ? null + : () async { + final person = await showDialog( + context: context, + builder: (_) => AssignPersonDialog( + event: event, + people: people, + ), + ); + if (person != null) { + onAssignPerson!(event, person); + } + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +class AssignPersonDialog extends StatelessWidget { + const AssignPersonDialog({ + super.key, + required this.event, + required this.people, + }); + + final CalendarEvent event; + final List people; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Esem\u00e9ny besorol\u00e1sa'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.title, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w900, + fontSize: 17, + ), + ), + const SizedBox(height: 4), + Text( + '${formatDate(event.date)} ${event.start}', + style: const TextStyle(color: Colors.black54), + ), + const SizedBox(height: 16), + ...people.map( + (person) => Card( + child: ListTile( + leading: FamilyAvatar(person: person, radius: 18), + title: Text(person.name), + onTap: () => Navigator.pop(context, person), + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + ], + ); + } +} + +class EventCard extends StatelessWidget { + const EventCard({super.key, required this.event, this.person, this.onTap}); + + final CalendarEvent event; + final Person? person; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 5, + height: 66, + decoration: BoxDecoration( + color: event.color, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 48, + child: Text( + event.start, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.title, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + event.place.isEmpty + ? '${event.start}\u2013${event.end}' + : '${event.start}\u2013${event.end} \u00b7 ${event.place}', + style: const TextStyle(color: Colors.black54), + ), + const SizedBox(height: 6), + AudienceChip(person: person, event: event), + ], + ), + ), + Icon( + event.hasTodos ? Icons.task_alt_rounded : Icons.more_horiz, + color: event.hasTodos ? sage : Colors.black38, + ), + ], + ), + ), + ), + ); + } +} + +class FamilyAvatar extends StatelessWidget { + const FamilyAvatar({super.key, required this.person, this.radius = 22}); + + final Person person; + final double radius; + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: person.color, + child: Text(person.avatar, style: TextStyle(fontSize: radius * .95)), + ); + } +} + +class AudienceChip extends StatelessWidget { + const AudienceChip({super.key, required this.person, required this.event}); + + final Person? person; + final CalendarEvent event; + + @override + Widget build(BuildContext context) { + final chipColor = person?.color ?? (event.isUncategorized ? orange : sage); + final label = + person?.name ?? + (event.isUncategorized ? 'Kategoriz\u00e1latlan' : 'Mindenki'); + final icon = person != null + ? Icons.person_outline + : event.isUncategorized + ? Icons.inbox_outlined + : Icons.groups_2_outlined; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: chipColor.withValues(alpha: .12), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: chipColor), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + color: chipColor, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/data.dart b/lib/data.dart new file mode 100644 index 0000000..9849ff3 --- /dev/null +++ b/lib/data.dart @@ -0,0 +1,726 @@ +part of 'main.dart'; + +const ink = Color(0xFF18332D); +const sage = Color(0xFF2F6B5B); +const mint = Color(0xFFE7F1EC); +const cream = Color(0xFFF7F5EF); +const orange = Color(0xFFE89552); +const everyonePersonId = '_everyone'; +const defaultJarvisApiUrl = 'https://api-test.a-web.hu'; +const legacyJarvisApiUrl = 'http://80.211.202.74'; +const appVersionLabel = 'v1.0.0+1'; + +const avatarPresets = [ + AvatarPreset('boy', 'Fi\u00fa', '\u{1f9d2}'), + AvatarPreset('girl', 'L\u00e1ny', '\u{1f467}'), + AvatarPreset('man', 'F\u00e9rfi', '\u{1f468}'), + AvatarPreset('woman', 'N\u0151', '\u{1f469}'), + AvatarPreset('dog', 'Kutya', '\u{1f436}'), + AvatarPreset('cat', 'Macska', '\u{1f431}'), + AvatarPreset('snowman', 'H\u00f3ember', '\u2603\ufe0f'), + AvatarPreset('strawberry', 'Eper', '\u{1f353}'), + AvatarPreset('sun', 'Napocska', '\u2600\ufe0f'), + AvatarPreset('flower', 'Vir\u00e1g', '\u{1f338}'), + AvatarPreset('car', 'Aut\u00f3', '\u{1f697}'), + AvatarPreset('rocket', 'Rak\u00e9ta', '\u{1f680}'), +]; + +const personColors = [ + Color(0xFF7189C8), + Color(0xFFE89552), + Color(0xFF9B78B4), + Color(0xFF79A894), + Color(0xFFD65A6F), + Color(0xFFD6A74D), + Color(0xFF4C9BCF), + Color(0xFF6A9F58), +]; + +class AvatarPreset { + const AvatarPreset(this.id, this.label, this.symbol); + + final String id; + final String label; + final String symbol; +} + +class AppThemeStyle { + const AppThemeStyle({ + required this.id, + required this.name, + required this.description, + required this.seed, + required this.surface, + }); + + final String id; + final String name; + final String description; + final Color seed; + final Color surface; +} + +const appThemes = [ + AppThemeStyle( + id: 'sage', + name: 'Zs\u00e1lya otthon', + description: 'Nyugodt, term\u00e9szetes csal\u00e1di alapst\u00edlus.', + seed: sage, + surface: cream, + ), + AppThemeStyle( + id: 'berry', + name: 'Eperkr\u00e9m', + description: + 'Melegebb, j\u00e1t\u00e9kosabb sz\u00ednek gyerekes csal\u00e1dokhoz.', + seed: Color(0xFFD65A6F), + surface: Color(0xFFFFF5F6), + ), + AppThemeStyle( + id: 'sky', + name: 'Tiszta \u00e9g', + description: + 'H\u0171v\u00f6sebb, rendezett, napt\u00e1rk\u00f6zpont\u00fa hangulat.', + seed: Color(0xFF4C9BCF), + surface: Color(0xFFF1F7FB), + ), + AppThemeStyle( + id: 'sunny', + name: 'Naps\u00e1rga', + description: + 'Vil\u00e1gos, energikus, konyhai \u00e9s heti tervez\u0151s \u00e9rzet.', + seed: Color(0xFFD6A74D), + surface: Color(0xFFFFFAED), + ), +]; + +class AppJsonStore { + Future _file() async { + try { + final dir = await getApplicationSupportDirectory(); + return File('${dir.path}${Platform.pathSeparator}kozos_ter.json'); + } on MissingPluginException { + return File( + '${Directory.systemTemp.path}${Platform.pathSeparator}kozos_ter.json', + ); + } on UnsupportedError { + return File( + '${Directory.systemTemp.path}${Platform.pathSeparator}kozos_ter.json', + ); + } + } + + Future load() async { + try { + final file = await _file(); + if (!await file.exists()) { + final settings = AppSettings( + people: defaultPeople(), + themeId: 'sage', + googleConnections: defaultGoogleConnections(), + syncedEvents: const [], + todoSuggestions: const [], + jarvisSettings: JarvisSettings.defaults(), + ); + await save(settings); + return settings; + } + + final raw = await file.readAsString(); + final json = jsonDecode(raw) as Map; + return AppSettings.fromJson(json); + } catch (_) { + return AppSettings( + people: defaultPeople(), + themeId: 'sage', + googleConnections: defaultGoogleConnections(), + syncedEvents: const [], + todoSuggestions: const [], + jarvisSettings: JarvisSettings.defaults(), + ); + } + } + + Future save(AppSettings settings) async { + final file = await _file(); + await file.parent.create(recursive: true); + const encoder = JsonEncoder.withIndent(' '); + await file.writeAsString(encoder.convert(settings.toJson())); + } +} + +class AppSettings { + AppSettings({ + required this.people, + required this.themeId, + required this.googleConnections, + required this.syncedEvents, + required this.todoSuggestions, + required this.jarvisSettings, + }); + + final List people; + final String themeId; + final List googleConnections; + final List syncedEvents; + final List todoSuggestions; + final JarvisSettings jarvisSettings; + + factory AppSettings.fromJson(Map json) { + final rawPeople = json['people']; + final rawConnections = json['googleConnections']; + final rawSyncedEvents = json['syncedEvents']; + final rawTodoSuggestions = json['todoSuggestions']; + final rawJarvisSettings = json['jarvisSettings']; + return AppSettings( + people: rawPeople is List + ? normalizeDefaultPeople( + rawPeople + .whereType>() + .map(Person.fromJson) + .toList(), + ) + : defaultPeople(), + themeId: json['themeId'] as String? ?? 'sage', + googleConnections: rawConnections is List + ? rawConnections + .whereType>() + .map(GoogleCalendarConnection.fromJson) + .toList() + : defaultGoogleConnections(), + syncedEvents: rawSyncedEvents is List + ? rawSyncedEvents + .whereType>() + .map(CalendarEvent.fromJson) + .toList() + : const [], + todoSuggestions: rawTodoSuggestions is List + ? rawTodoSuggestions.whereType().toList() + : const [], + jarvisSettings: rawJarvisSettings is Map + ? JarvisSettings.fromJson(rawJarvisSettings) + : JarvisSettings.defaults(), + ); + } + + Map toJson() => { + 'themeId': themeId, + 'people': people.map((person) => person.toJson()).toList(), + 'googleConnections': googleConnections + .map((connection) => connection.toJson()) + .toList(), + 'syncedEvents': syncedEvents.map((event) => event.toJson()).toList(), + 'todoSuggestions': todoSuggestions, + 'jarvisSettings': jarvisSettings.toJson(), + }; +} + +class JarvisSettings { + JarvisSettings({ + required this.apiUrl, + required this.apiToken, + this.deviceName, + this.deviceId, + this.deviceToken, + this.registrationStatus = 'notRegistered', + }); + + final String apiUrl; + final String apiToken; + final String? deviceName; + final int? deviceId; + final String? deviceToken; + final String registrationStatus; + + factory JarvisSettings.defaults() => + JarvisSettings(apiUrl: defaultJarvisApiUrl, apiToken: ''); + + factory JarvisSettings.fromJson(Map json) => JarvisSettings( + apiUrl: (json['apiUrl'] as String?) == legacyJarvisApiUrl + ? defaultJarvisApiUrl + : json['apiUrl'] as String? ?? JarvisSettings.defaults().apiUrl, + apiToken: json['apiToken'] as String? ?? '', + deviceName: json['deviceName'] as String?, + deviceId: json['deviceId'] as int?, + deviceToken: json['deviceToken'] as String?, + registrationStatus: + json['registrationStatus'] as String? ?? 'notRegistered', + ); + + JarvisSettings copyWith({ + String? apiUrl, + String? apiToken, + String? deviceName, + int? deviceId, + String? deviceToken, + String? registrationStatus, + bool clearDevice = false, + }) { + return JarvisSettings( + apiUrl: apiUrl ?? this.apiUrl, + apiToken: apiToken ?? this.apiToken, + deviceName: clearDevice ? null : deviceName ?? this.deviceName, + deviceId: clearDevice ? null : deviceId ?? this.deviceId, + deviceToken: clearDevice ? null : deviceToken ?? this.deviceToken, + registrationStatus: clearDevice + ? 'notRegistered' + : registrationStatus ?? this.registrationStatus, + ); + } + + Map toJson() => { + 'apiUrl': apiUrl, + 'apiToken': apiToken, + 'deviceName': deviceName, + 'deviceId': deviceId, + 'deviceToken': deviceToken, + 'registrationStatus': registrationStatus, + }; +} + +class GoogleCalendarConnection { + GoogleCalendarConnection({ + required this.id, + required this.accountEmail, + required this.enabledCalendarIds, + this.calendarPersonIds = const {}, + required this.lastSync, + }); + + final String id; + final String accountEmail; + final List enabledCalendarIds; + final Map calendarPersonIds; + final DateTime? lastSync; + + GoogleCalendarConnection copyWith({ + String? accountEmail, + List? enabledCalendarIds, + Map? calendarPersonIds, + DateTime? lastSync, + }) { + return GoogleCalendarConnection( + id: id, + accountEmail: accountEmail ?? this.accountEmail, + enabledCalendarIds: enabledCalendarIds ?? this.enabledCalendarIds, + calendarPersonIds: calendarPersonIds ?? this.calendarPersonIds, + lastSync: lastSync ?? this.lastSync, + ); + } + + factory GoogleCalendarConnection.fromJson(Map json) { + final rawCalendarIds = json['enabledCalendarIds']; + final rawCalendarPersonIds = json['calendarPersonIds']; + return GoogleCalendarConnection( + id: json['id'] as String? ?? uniqueId(), + accountEmail: json['accountEmail'] as String? ?? '', + enabledCalendarIds: rawCalendarIds is List + ? rawCalendarIds.whereType().toList() + : const [], + calendarPersonIds: rawCalendarPersonIds is Map + ? rawCalendarPersonIds.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ) + : const {}, + lastSync: DateTime.tryParse(json['lastSync'] as String? ?? ''), + ); + } + + Map toJson() => { + 'id': id, + 'accountEmail': accountEmail, + 'enabledCalendarIds': enabledCalendarIds, + 'calendarPersonIds': calendarPersonIds, + 'lastSync': lastSync?.toIso8601String(), + }; +} + +class GoogleCalendarOption { + const GoogleCalendarOption({ + required this.id, + required this.name, + required this.description, + required this.color, + }); + + final String id; + final String name; + final String description; + final Color color; +} + +const availableGoogleCalendars = [ + GoogleCalendarOption( + id: 'primary', + name: 'Saj\u00e1t napt\u00e1r', + description: 'A f\u0151 Google Calendar esem\u00e9nyeid.', + color: Color(0xFF4285F4), + ), + GoogleCalendarOption( + id: 'family', + name: 'Csal\u00e1di napt\u00e1r', + description: + 'K\u00f6z\u00f6s csal\u00e1di programok \u00e9s eml\u00e9keztet\u0151k.', + color: Color(0xFF34A853), + ), + GoogleCalendarOption( + id: 'school', + name: 'Ovi / iskola', + description: + 'Iskolai, \u00f3vodai \u00e9s k\u00fcl\u00f6n\u00f3r\u00e1s esem\u00e9nyek.', + color: Color(0xFFFBBC05), + ), + GoogleCalendarOption( + id: 'work', + name: 'Munka', + description: + 'Munkahelyi id\u0151pontok, ha szeretn\u00e9d l\u00e1tni \u0151ket.', + color: Color(0xFF9B78B4), + ), +]; + +List defaultGoogleConnections() => []; + +class Person { + Person({ + required this.id, + required this.name, + required this.colorValue, + required this.avatar, + this.nextEventLimit = 1, + }); + + final String id; + final String name; + final int colorValue; + final String avatar; + final int nextEventLimit; + + Color get color => Color(colorValue); + + Person copyWith({ + String? name, + int? colorValue, + String? avatar, + int? nextEventLimit, + }) { + return Person( + id: id, + name: name ?? this.name, + colorValue: colorValue ?? this.colorValue, + avatar: avatar ?? this.avatar, + nextEventLimit: nextEventLimit ?? this.nextEventLimit, + ); + } + + factory Person.fromJson(Map json) { + return Person( + id: json['id'] as String? ?? uniqueId(), + name: json['name'] as String? ?? 'Csal\u00e1dtag', + colorValue: json['colorValue'] as int? ?? personColors.first.toARGB32(), + avatar: json['avatar'] as String? ?? avatarPresets.first.symbol, + nextEventLimit: json['nextEventLimit'] as int? ?? 1, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'colorValue': colorValue, + 'avatar': avatar, + 'nextEventLimit': nextEventLimit, + }; +} + +List defaultPeople() => []; + +List normalizeDefaultPeople(List current) { + const seededIds = {'zsolt', 'eniko', 'balint', 'emilia', 'noemi'}; + return current.where((person) => !seededIds.contains(person.id)).toList(); +} + +class CalendarEvent { + CalendarEvent( + this.title, + this.start, + this.end, + this.color, { + required this.date, + this.place = '', + this.personId, + this.externalId, + this.calendarId, + this.calendarName, + this.note = '', + this.hasTodos = false, + this.todos = const [], + this.source = EventSource.local, + }); + + final String title; + final String start; + final String end; + final Color color; + final String place; + final String? personId; + final DateTime date; + final String? externalId; + final String? calendarId; + final String? calendarName; + final String note; + final bool hasTodos; + final List todos; + final EventSource source; + + bool get isSynced => source == EventSource.deviceCalendar; + bool get isUncategorized => personId == null; + bool get isForEveryone => personId == everyonePersonId; + + CalendarEvent copyWith({ + String? title, + String? start, + String? end, + Color? color, + String? place, + String? personId, + DateTime? date, + String? note, + bool? hasTodos, + List? todos, + bool clearPerson = false, + }) { + return CalendarEvent( + title ?? this.title, + start ?? this.start, + end ?? this.end, + color ?? this.color, + date: date ?? this.date, + place: place ?? this.place, + personId: clearPerson ? null : personId ?? this.personId, + externalId: externalId, + calendarId: calendarId, + calendarName: calendarName, + note: note ?? this.note, + hasTodos: hasTodos ?? this.hasTodos, + todos: todos ?? this.todos, + source: source, + ); + } + + factory CalendarEvent.fromJson(Map json) { + return CalendarEvent( + json['title'] as String? ?? 'N\u00e9vtelen esem\u00e9ny', + json['start'] as String? ?? '00:00', + json['end'] as String? ?? '', + Color(json['colorValue'] as int? ?? sage.toARGB32()), + date: DateTime.tryParse(json['date'] as String? ?? '') ?? DateTime.now(), + place: json['place'] as String? ?? '', + personId: json['personId'] as String?, + externalId: json['externalId'] as String?, + calendarId: json['calendarId'] as String?, + calendarName: json['calendarName'] as String?, + note: json['note'] as String? ?? '', + hasTodos: json['hasTodos'] as bool? ?? false, + todos: json['todos'] is List + ? (json['todos'] as List).whereType().toList() + : const [], + source: EventSource.values.firstWhere( + (source) => source.name == json['source'], + orElse: () => EventSource.local, + ), + ); + } + + factory CalendarEvent.fromDeviceEvent( + device_calendar.Event event, + device_calendar.Calendar calendar, { + String? personId, + }) { + final startDate = event.start?.toLocal() ?? DateTime.now(); + final endDate = event.end?.toLocal(); + return CalendarEvent( + event.title?.trim().isNotEmpty == true + ? event.title!.trim() + : 'N\u00e9vtelen napt\u00e1resem\u00e9ny', + event.allDay == true ? 'Eg\u00e9sz nap' : formatTime(startDate), + event.allDay == true || endDate == null ? '' : formatTime(endDate), + Color(calendar.color ?? const Color(0xFF4285F4).toARGB32()), + date: DateTime(startDate.year, startDate.month, startDate.day), + place: [ + calendar.name ?? 'Napt\u00e1r', + if ((event.location ?? '').trim().isNotEmpty) event.location!.trim(), + ].join(' \u00b7 '), + externalId: event.eventId, + calendarId: calendar.id, + calendarName: calendar.name, + personId: personId, + note: event.description ?? '', + source: EventSource.deviceCalendar, + ); + } + + Map toJson() => { + 'title': title, + 'start': start, + 'end': end, + 'colorValue': color.toARGB32(), + 'date': date.toIso8601String(), + 'place': place, + 'personId': personId, + 'externalId': externalId, + 'calendarId': calendarId, + 'calendarName': calendarName, + 'note': note, + 'hasTodos': hasTodos, + 'todos': todos, + 'source': source.name, + }; +} + +enum EventSource { local, deviceCalendar } + +class ShoppingItem { + ShoppingItem(this.name, this.amount, this.category, {this.done = false}); + + final String name; + final String amount; + final String category; + bool done; +} + +class StockItem { + StockItem(this.name, this.amount, this.unit, this.location, this.expiry); + + String name; + int amount; + String unit; + String location; + String expiry; +} + +class JarvisProduct { + const JarvisProduct({ + required this.barcode, + required this.name, + this.brand, + this.quantity, + this.imageUrl, + this.source, + this.fromCache = false, + }); + + final String barcode; + final String name; + final String? brand; + final String? quantity; + final String? imageUrl; + final String? source; + final bool fromCache; + + factory JarvisProduct.fromJson(Map json) => JarvisProduct( + barcode: json['barcode'] as String? ?? '', + name: json['name'] as String? ?? '', + brand: json['brand'] as String?, + quantity: json['quantity'] as String?, + imageUrl: json['imageUrl'] as String?, + source: json['source'] as String?, + fromCache: json['fromCache'] as bool? ?? false, + ); + + String get displayName { + final parts = [ + name, + if (brand != null && brand!.trim().isNotEmpty) brand!, + if (quantity != null && quantity!.trim().isNotEmpty) quantity!, + ]; + return parts.join(' \u00b7 '); + } +} + +class WeatherSnapshot { + const WeatherSnapshot({ + required this.locationName, + required this.temperature, + required this.description, + required this.icon, + }); + + final String locationName; + final double? temperature; + final String description; + final IconData icon; + + String get temperatureLabel => + temperature == null ? '\u2014' : '${temperature!.round()}\u00b0C'; +} + +class WeatherService { + Future loadCurrentWeather() async { + try { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return const WeatherSnapshot( + locationName: 'Helymeghat\u00e1roz\u00e1s n\u00e9lk\u00fcl', + temperature: null, + description: 'id\u0151j\u00e1r\u00e1s nem el\u00e9rhet\u0151', + icon: Icons.location_off_outlined, + ); + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 8), + ), + ); + final locationName = await _locationName(position); + final uri = Uri.https('api.open-meteo.com', '/v1/forecast', { + 'latitude': position.latitude.toStringAsFixed(4), + 'longitude': position.longitude.toStringAsFixed(4), + 'current': 'temperature_2m,weather_code', + }); + final response = await http.get(uri).timeout(const Duration(seconds: 8)); + if (response.statusCode != 200) { + throw HttpException('weather ${response.statusCode}'); + } + + final payload = jsonDecode(response.body) as Map; + final current = payload['current'] as Map?; + final temperature = (current?['temperature_2m'] as num?)?.toDouble(); + final code = (current?['weather_code'] as num?)?.toInt(); + return WeatherSnapshot( + locationName: locationName, + temperature: temperature, + description: weatherDescription(code), + icon: weatherIcon(code), + ); + } catch (_) { + return const WeatherSnapshot( + locationName: 'Offline m\u00f3d', + temperature: null, + description: 'id\u0151j\u00e1r\u00e1s k\u00e9s\u0151bb friss\u00fcl', + icon: Icons.cloud_off_outlined, + ); + } + } + + Future _locationName(Position position) async { + try { + final places = await geocoding.Geocoding().placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + final place = places.firstOrNull; + return [ + place?.locality, + if (place?.locality == null) place?.subAdministrativeArea, + ].whereType().where((part) => part.isNotEmpty).join(', '); + } catch (_) { + return 'Aktu\u00e1lis hely'; + } + } +} diff --git a/lib/dialogs.dart b/lib/dialogs.dart new file mode 100644 index 0000000..90fc9ce --- /dev/null +++ b/lib/dialogs.dart @@ -0,0 +1,1263 @@ +part of 'main.dart'; + +class PersonDraft { + PersonDraft({ + required this.name, + required this.color, + required this.avatar, + required this.nextEventLimit, + }); + + final String name; + final Color color; + final String avatar; + final int nextEventLimit; +} + +class PersonDialog extends StatefulWidget { + const PersonDialog({ + super.key, + required this.title, + this.initialName = '', + required this.initialColor, + required this.initialAvatar, + this.initialNextEventLimit = 1, + }); + + final String title; + final String initialName; + final Color initialColor; + final String initialAvatar; + final int initialNextEventLimit; + + @override + State createState() => _PersonDialogState(); +} + +class _PersonDialogState extends State { + late final TextEditingController name; + late Color selectedColor; + late String selectedAvatar; + late int selectedNextEventLimit; + + @override + void initState() { + super.initState(); + name = TextEditingController(text: widget.initialName); + selectedColor = widget.initialColor; + selectedAvatar = widget.initialAvatar; + selectedNextEventLimit = widget.initialNextEventLimit; + } + + @override + void dispose() { + name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: 440, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: name, + autofocus: true, + decoration: const InputDecoration(labelText: 'N\u00e9v'), + ), + const SizedBox(height: 18), + const Text( + 'Sz\u00edn', + style: TextStyle(color: ink, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: personColors.map((color) { + final selected = color.toARGB32() == selectedColor.toARGB32(); + return InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => setState(() => selectedColor = color), + child: CircleAvatar( + backgroundColor: color, + child: selected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 18), + const Text( + 'Avatar / ovis jel', + style: TextStyle(color: ink, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: avatarPresets.map((preset) { + final selected = preset.symbol == selectedAvatar; + return ChoiceChip( + selected: selected, + label: Text('${preset.symbol} ${preset.label}'), + onSelected: (_) => + setState(() => selectedAvatar = preset.symbol), + ); + }).toList(), + ), + const SizedBox(height: 18), + const Text( + 'K\u00f6vetkez\u0151 esem\u00e9nyek sz\u00e1ma a csal\u00e1di widgeten', + style: TextStyle(color: ink, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ButtonSegment(value: 5, label: Text('5')), + ], + selected: {selectedNextEventLimit}, + onSelectionChanged: (values) => + setState(() => selectedNextEventLimit = values.first), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () { + final trimmed = name.text.trim(); + if (trimmed.isEmpty) return; + Navigator.pop( + context, + PersonDraft( + name: trimmed, + color: selectedColor, + avatar: selectedAvatar, + nextEventLimit: selectedNextEventLimit, + ), + ); + }, + child: const Text('Ment\u00e9s'), + ), + ], + ); + } +} + +class EventInput { + EventInput({ + required this.title, + required this.start, + required this.date, + required this.personId, + required this.note, + required this.hasTodos, + required this.todos, + }); + + final String title; + final String start; + final DateTime date; + final String? personId; + final String note; + final bool hasTodos; + final List todos; +} + +class EventAddDialog extends StatefulWidget { + const EventAddDialog({ + super.key, + required this.people, + this.event, + this.todoSuggestions = const [], + }); + + final List people; + final CalendarEvent? event; + final List todoSuggestions; + + @override + State createState() => _EventAddDialogState(); +} + +class _EventAddDialogState extends State { + late final TextEditingController title; + late final TextEditingController start; + late final TextEditingController date; + late final TextEditingController note; + final todo = TextEditingController(); + late bool hasTodos; + late List todos; + String? personId; + + @override + void initState() { + super.initState(); + final event = widget.event; + title = TextEditingController(text: event?.title ?? ''); + start = TextEditingController(text: event?.start ?? ''); + date = TextEditingController( + text: event == null ? formatDate(DateTime.now()) : formatDate(event.date), + ); + note = TextEditingController(text: event?.note ?? ''); + hasTodos = event?.hasTodos ?? false; + todos = [...event?.todos ?? const []]; + personId = event?.personId == everyonePersonId ? null : event?.personId; + } + + @override + void dispose() { + title.dispose(); + start.dispose(); + date.dispose(); + note.dispose(); + todo.dispose(); + super.dispose(); + } + + void _addTodo(String value) { + final cleaned = value.trim(); + if (cleaned.isEmpty || todos.contains(cleaned)) return; + setState(() { + todos.add(cleaned); + todo.clear(); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.event == null + ? '\u00daj esem\u00e9ny' + : 'Esem\u00e9ny szerkeszt\u00e9se', + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: title, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Mi legyen az esem\u00e9ny?', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: start, + decoration: const InputDecoration( + labelText: 'Id\u0151pont (pl. 14:30)', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: date, + decoration: const InputDecoration( + labelText: 'D\u00e1tum (pl. 2026.08.01)', + ), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: personId, + decoration: const InputDecoration( + labelText: 'Kire vonatkozik?', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Mindenki'), + ), + ...widget.people.map( + (person) => DropdownMenuItem( + value: person.id, + child: Text('${person.avatar} ${person.name}'), + ), + ), + ], + onChanged: (value) => setState(() => personId = value), + ), + const SizedBox(height: 12), + TextField( + controller: note, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Megjegyz\u00e9s'), + ), + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: hasTodos, + onChanged: (value) => setState(() => hasTodos = value ?? false), + title: const Text('Teend\u0151 van'), + controlAffinity: ListTileControlAffinity.leading, + ), + if (hasTodos) ...[ + Row( + children: [ + Expanded( + child: RawAutocomplete( + textEditingController: todo, + focusNode: FocusNode(), + optionsBuilder: (value) { + final query = value.text.trim().toLowerCase(); + if (query.isEmpty) return widget.todoSuggestions; + return widget.todoSuggestions.where( + (item) => item.toLowerCase().contains(query), + ); + }, + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Teend\u0151 hozz\u00e1ad\u00e1sa', + ), + onSubmitted: _addTodo, + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Material( + elevation: 4, + child: ListView( + padding: EdgeInsets.zero, + shrinkWrap: true, + children: options + .map( + (option) => ListTile( + title: Text(option), + onTap: () => onSelected(option), + ), + ) + .toList(), + ), + ); + }, + onSelected: _addTodo, + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: () => _addTodo(todo.text), + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 8), + ...todos.map( + (item) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.check_circle_outline, + color: sage, + ), + title: Text(item), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() => todos.remove(item)), + ), + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () { + final eventDate = parseDate(date.text) ?? DateTime.now(); + Navigator.pop( + context, + EventInput( + title: title.text, + start: start.text, + date: eventDate, + personId: personId, + note: note.text, + hasTodos: hasTodos, + todos: hasTodos ? todos : const [], + ), + ); + }, + child: Text( + widget.event == null ? 'Hozz\u00e1ad\u00e1s' : 'Ment\u00e9s', + ), + ), + ], + ); + } +} + +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: sage), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle( + color: ink, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 5), + Text(subtitle, style: const TextStyle(color: Colors.black45)), + ], + ), + ); + } +} + +class QuickAddDialog extends StatefulWidget { + const QuickAddDialog({ + super.key, + required this.title, + required this.firstLabel, + required this.secondLabel, + }); + + final String title; + final String firstLabel; + final String secondLabel; + + @override + State createState() => _QuickAddDialogState(); +} + +class _QuickAddDialogState extends State { + final first = TextEditingController(); + final second = TextEditingController(); + + @override + void dispose() { + first.dispose(); + second.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: first, + autofocus: true, + decoration: InputDecoration(labelText: widget.firstLabel), + ), + const SizedBox(height: 12), + TextField( + controller: second, + decoration: InputDecoration(labelText: widget.secondLabel), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, [first.text, second.text]), + child: const Text('Hozz\u00e1ad\u00e1s'), + ), + ], + ); + } +} + +class StockInput { + StockInput({ + required this.name, + required this.barcode, + required this.amount, + required this.unit, + required this.location, + required this.expiry, + }); + + final String name; + final String barcode; + final int amount; + final String unit; + final String location; + final String expiry; +} + +class StockAddDialog extends StatefulWidget { + const StockAddDialog({ + super.key, + required this.jarvisSettings, + this.initialItem, + }); + + final JarvisSettings jarvisSettings; + final StockItem? initialItem; + + @override + State createState() => _StockAddDialogState(); +} + +class _StockAddDialogState extends State { + final name = TextEditingController(); + final barcode = TextEditingController(); + final amount = TextEditingController(text: '1'); + final unit = TextEditingController(text: 'db'); + final expiry = TextEditingController(); + final productFocus = FocusNode(); + String location = 'Kamra'; + bool loadingProducts = false; + String? apiMessage; + + bool get editing => widget.initialItem != null; + + @override + void initState() { + super.initState(); + final item = widget.initialItem; + if (item == null) return; + name.text = item.name; + amount.text = item.amount.toString(); + unit.text = item.unit; + location = item.location; + expiry.text = item.expiry == 'nincs megadva' ? '' : item.expiry; + } + + @override + void dispose() { + name.dispose(); + barcode.dispose(); + amount.dispose(); + unit.dispose(); + expiry.dispose(); + productFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(editing ? 'K\u00e9szlet szerkeszt\u00e9se' : 'K\u00e9szlet felv\u00e9tele'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: barcode, + decoration: const InputDecoration( + labelText: 'Vonalk\u00f3d', + hintText: 'kamera vagy k\u00e9zi bevitel', + ), + onSubmitted: (_) => _lookupBarcode(), + ), + ), + const SizedBox(width: 10), + IconButton.filledTonal( + tooltip: 'Vonalk\u00f3d beolvas\u00e1sa', + onPressed: _scanBarcode, + icon: const Icon(Icons.qr_code_scanner), + ), + IconButton.filledTonal( + tooltip: 'Keres\u00e9s JARVIS API-ban', + onPressed: loadingProducts ? null : _lookupBarcode, + icon: loadingProducts + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.manage_search), + ), + ], + ), + const SizedBox(height: 12), + RawAutocomplete( + textEditingController: name, + focusNode: productFocus, + displayStringForOption: (product) => product.displayName, + optionsBuilder: (value) async { + final query = value.text.trim(); + if (query.length < 2) { + return const Iterable.empty(); + } + return _searchProducts(query); + }, + onSelected: _applyProduct, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + decoration: const InputDecoration( + labelText: '\u00c9lelmiszer neve', + hintText: + 'G\u00e9pelj, \u00e9s a JARVIS felk\u00edn\u00e1lja a mentett term\u00e9keket', + ), + ); + }, + optionsViewBuilder: (context, onSelected, options) => Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(16), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 260, + maxWidth: 420, + ), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final product = options.elementAt(index); + return ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(product.name), + subtitle: Text( + [ + if (product.brand != null) product.brand!, + if (product.quantity != null) product.quantity!, + product.barcode, + ].join(' \u00b7 '), + ), + onTap: () => onSelected(product), + ); + }, + ), + ), + ), + ), + ), + if (apiMessage != null) ...[ + const SizedBox(height: 8), + Text( + apiMessage!, + style: TextStyle( + color: apiMessage!.startsWith('Hiba') + ? Colors.red.shade700 + : sage, + fontWeight: FontWeight.w700, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: amount, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Mennyis\u00e9g', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: unit, + decoration: const InputDecoration(labelText: 'Egys\u00e9g'), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: location, + decoration: const InputDecoration(labelText: 'Hely'), + items: const [ + DropdownMenuItem( + value: 'H\u0171t\u0151', + child: Text('H\u0171t\u0151szekr\u00e9ny'), + ), + DropdownMenuItem( + value: 'Kamra', + child: Text('Sz\u00e1raz\u00e1ru'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() => location = value); + }, + ), + const SizedBox(height: 12), + TextField( + controller: expiry, + decoration: const InputDecoration( + labelText: 'Lej\u00e1rat', + hintText: 'pl. 2026.08.31 vagy nincs megadva', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () { + final trimmedName = name.text.trim(); + if (trimmedName.isEmpty) return; + Navigator.pop( + context, + StockInput( + name: trimmedName, + barcode: barcode.text.trim(), + amount: int.tryParse(amount.text.trim()) ?? 1, + unit: unit.text.trim().isEmpty ? 'db' : unit.text.trim(), + location: location, + expiry: expiry.text.trim().isEmpty + ? 'nincs megadva' + : expiry.text.trim(), + ), + ); + }, + child: Text(editing ? 'Ment\u00e9s' : 'Hozz\u00e1ad\u00e1s'), + ), + ], + ); + } + + bool get _jarvisConfigured => + widget.jarvisSettings.apiUrl.trim().isNotEmpty && + widget.jarvisSettings.apiToken.trim().isNotEmpty; + + Map get _headers => { + 'X-Otthon-Token': widget.jarvisSettings.apiToken.trim(), + }; + + Future> _searchProducts(String query) async { + if (!_jarvisConfigured) return const []; + try { + final baseUri = Uri.parse( + normalizeJarvisUrl(widget.jarvisSettings.apiUrl), + ); + final uri = baseUri.resolve( + '/api/products?query=${Uri.encodeQueryComponent(query)}&take=20', + ); + final response = await http + .get(uri, headers: _headers) + .timeout(const Duration(seconds: 8)); + if (response.statusCode != 200) return const []; + final payload = jsonDecode(response.body); + if (payload is! Map) return const []; + final rawProducts = payload['products']; + if (rawProducts is! List) return const []; + return rawProducts + .whereType>() + .map(JarvisProduct.fromJson) + .where((product) => product.name.isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + Future _lookupBarcode() async { + final normalized = barcode.text.replaceAll(RegExp(r'\D'), ''); + if (normalized.isEmpty) { + setState(() => apiMessage = 'Adj meg vagy olvass be vonalk\u00f3dot.'); + return; + } + if (!_jarvisConfigured) { + setState(() => apiMessage = 'A JARVIS API nincs be\u00e1ll\u00edtva.'); + return; + } + + setState(() { + loadingProducts = true; + apiMessage = 'JARVIS keres\u00e9s: $normalized'; + }); + try { + final baseUri = Uri.parse( + normalizeJarvisUrl(widget.jarvisSettings.apiUrl), + ); + final response = await http + .get(baseUri.resolve('/api/products/$normalized'), headers: _headers) + .timeout(const Duration(seconds: 12)); + if (response.statusCode == 404) { + setState( + () => apiMessage = + 'Nem tal\u00e1ltam ilyen term\u00e9ket ($normalized). K\u00e9zzel felviheted.', + ); + return; + } + if (response.statusCode != 200) { + setState(() => apiMessage = 'Hiba: API ${response.statusCode}'); + return; + } + final payload = jsonDecode(response.body); + if (payload is! Map) { + setState(() => apiMessage = 'Hiba: hib\u00e1s API v\u00e1lasz.'); + return; + } + final product = JarvisProduct.fromJson(payload); + if (product.name.trim().isEmpty) { + setState( + () => apiMessage = + 'A JARVIS v\u00e1laszban nem volt term\u00e9kn\u00e9v.', + ); + return; + } + _applyProduct(product); + setState( + () => apiMessage = product.fromCache + ? 'Tal\u00e1lat cache-b\u0151l: ${product.name}' + : 'Tal\u00e1lat Open Food Facts alapj\u00e1n: ${product.name}', + ); + } catch (error) { + setState(() => apiMessage = 'Hiba: $error'); + } finally { + if (mounted) setState(() => loadingProducts = false); + } + } + + void _applyProduct(JarvisProduct product) { + setState(() { + barcode.text = product.barcode; + name.text = product.name; + if (product.quantity != null && product.quantity!.trim().isNotEmpty) { + unit.text = product.quantity!; + } + }); + } + + Future _scanBarcode() async { + final result = await showDialog( + context: context, + builder: (_) => const BarcodeScanDialog(), + ); + if (result == null || result.trim().isEmpty) return; + setState(() => apiMessage = 'Vonalk\u00f3d beolvasva: ${result.trim()}'); + barcode.text = result.trim(); + await _lookupBarcode(); + } +} + +class BarcodeScanDialog extends StatefulWidget { + const BarcodeScanDialog({super.key}); + + @override + State createState() => _BarcodeScanDialogState(); +} + +class _BarcodeScanDialogState extends State { + final controller = MobileScannerController( + facing: CameraFacing.front, + formats: const [ + BarcodeFormat.ean13, + BarcodeFormat.ean8, + BarcodeFormat.upcA, + BarcodeFormat.upcE, + BarcodeFormat.code128, + ], + ); + bool handled = false; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Vonalk\u00f3d beolvas\u00e1sa'), + content: SizedBox( + width: 520, + height: 360, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: controller, + onDetect: (capture) { + if (handled) return; + for (final code in capture.barcodes) { + final value = code.rawValue; + if (value == null || value.trim().isEmpty) continue; + handled = true; + Navigator.pop(context, value); + break; + } + }, + ), + const Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(12), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text( + 'Tartsd a vonalk\u00f3dot az el\u0151lapi kamera el\u00e9.', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + ], + ); + } +} + +typedef JarvisRegisterCallback = + Future Function(JarvisSettings settings, String deviceName); +typedef JarvisStatusCallback = + Future Function(JarvisSettings settings); + +class JarvisSettingsDialog extends StatefulWidget { + const JarvisSettingsDialog({ + super.key, + required this.settings, + required this.onRegister, + required this.onRefreshStatus, + }); + + final JarvisSettings settings; + final JarvisRegisterCallback onRegister; + final JarvisStatusCallback onRefreshStatus; + + @override + State createState() => _JarvisSettingsDialogState(); +} + +class _JarvisSettingsDialogState extends State { + late final TextEditingController apiUrl; + late final TextEditingController apiToken; + late final TextEditingController deviceName; + late JarvisSettings currentSettings; + bool registering = false; + bool refreshing = false; + String? message; + + @override + void initState() { + super.initState(); + currentSettings = widget.settings; + apiUrl = TextEditingController(text: currentSettings.apiUrl); + apiToken = TextEditingController(text: currentSettings.apiToken); + deviceName = TextEditingController( + text: currentSettings.deviceName ?? 'Family tablet', + ); + } + + @override + void dispose() { + apiUrl.dispose(); + apiToken.dispose(); + deviceName.dispose(); + super.dispose(); + } + + JarvisSettings get draft => currentSettings.copyWith( + apiUrl: normalizeJarvisUrl(apiUrl.text), + apiToken: apiToken.text.trim(), + ); + + @override + Widget build(BuildContext context) { + final status = currentSettings.registrationStatus; + final registered = + currentSettings.deviceId != null || + (currentSettings.deviceToken?.isNotEmpty ?? false); + final approved = status == 'approved'; + final pending = status == 'pending'; + final deviceToken = currentSettings.deviceToken; + final shortDeviceToken = deviceToken == null || deviceToken.length <= 10 + ? deviceToken + : '${deviceToken.substring(0, 6)}...${deviceToken.substring(deviceToken.length - 4)}'; + + return AlertDialog( + title: const Text('JARVIS Backend be\u00e1ll\u00edt\u00e1sok'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: apiUrl, + decoration: const InputDecoration( + labelText: 'API URL', + hintText: defaultJarvisApiUrl, + ), + ), + const SizedBox(height: 12), + TextField( + controller: apiToken, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Szerver token', + hintText: 'JARVIS API token', + ), + ), + const SizedBox(height: 12), + TextField( + controller: deviceName, + decoration: const InputDecoration( + labelText: 'Eszk\u00f6z neve', + hintText: 'pl. Konyhai tablet', + ), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: approved + ? mint + : pending + ? const Color(0xFFFFF4DD) + : const Color(0xFFF1F3EF), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + approved + ? Icons.verified_outlined + : pending + ? Icons.hourglass_top + : Icons.info_outline, + color: approved + ? sage + : pending + ? orange + : Colors.black45, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + approved + ? 'Az eszk\u00f6z j\u00f3v\u00e1hagyva \u00e9s haszn\u00e1lhat\u00f3.' + : pending + ? 'A regisztr\u00e1ci\u00f3 bek\u00fcldve, admin j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.' + : registered + ? 'Az eszk\u00f6z regisztr\u00e1lva van, de jelenleg nem akt\u00edv.' + : 'M\u00e9g nincs bek\u00fcld\u00f6tt regisztr\u00e1ci\u00f3.', + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + ), + ), + if (registered) ...[ + const SizedBox(height: 6), + Text( + 'Eszk\u00f6z: ${currentSettings.deviceName ?? deviceName.text}', + style: const TextStyle( + color: Colors.black54, + fontWeight: FontWeight.w700, + ), + ), + if (currentSettings.deviceId != null) + Text( + 'Azonos\u00edt\u00f3: #${currentSettings.deviceId}', + style: const TextStyle(color: Colors.black54), + ), + if (shortDeviceToken != null) + Text( + 'Eszk\u00f6z token: $shortDeviceToken', + style: const TextStyle(color: Colors.black54), + ), + ], + ], + ), + ), + ], + ), + ), + if (message != null) ...[ + const SizedBox(height: 10), + Text( + message!, + style: TextStyle( + color: message!.startsWith('Hiba') + ? Colors.red.shade700 + : sage, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: registering ? null : () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + if (registered) + TextButton.icon( + onPressed: registering || refreshing ? null : _clearRegistration, + icon: const Icon(Icons.link_off), + label: const Text('Regisztr\u00e1ci\u00f3 t\u00f6rl\u00e9se'), + ), + OutlinedButton.icon( + onPressed: registering || refreshing ? null : _refreshStatus, + icon: refreshing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: Text(refreshing ? 'Friss\u00edt\u00e9s...' : 'St\u00e1tusz'), + ), + OutlinedButton.icon( + onPressed: registering || refreshing ? null : _register, + icon: registering + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.app_registration), + label: Text( + registering ? 'K\u00fcld\u00e9s...' : 'Regisztr\u00e1ci\u00f3', + ), + ), + FilledButton( + onPressed: registering ? null : () => Navigator.pop(context, draft), + child: const Text('Ment\u00e9s'), + ), + ], + ); + } + + void _clearRegistration() { + setState(() { + currentSettings = draft.copyWith(clearDevice: true); + deviceName.text = 'Family tablet'; + message = + 'A tablet helyi regisztr\u00e1ci\u00f3ja t\u00f6r\u00f6lve. A szerveren nincs m\u00f3dos\u00edt\u00e1s.'; + }); + } + + Future _register() async { + setState(() { + registering = true; + message = null; + }); + try { + final registered = await widget.onRegister(draft, deviceName.text); + setState(() { + currentSettings = registered; + apiUrl.text = registered.apiUrl; + apiToken.text = registered.apiToken; + message = + 'Regisztr\u00e1ci\u00f3 bek\u00fcldve. A JARVIS admin fel\u00fcleten kell j\u00f3v\u00e1hagyni.'; + }); + } catch (error) { + setState(() => message = 'Hiba: $error'); + } finally { + if (mounted) setState(() => registering = false); + } + } + + Future _refreshStatus() async { + setState(() { + refreshing = true; + message = null; + }); + try { + final refreshed = await widget.onRefreshStatus(draft); + setState(() { + currentSettings = refreshed; + message = refreshed.registrationStatus == 'approved' + ? 'Az eszk\u00f6z j\u00f3v\u00e1 van hagyva.' + : 'Az eszk\u00f6z m\u00e9g j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.'; + }); + } catch (error) { + setState(() => message = 'Hiba: $error'); + } finally { + if (mounted) setState(() => refreshing = false); + } + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..ea2014c --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:async'; +import 'dart:math' as math; + +import 'package:device_calendar/device_calendar.dart' as device_calendar; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:geocoding/geocoding.dart' as geocoding; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:timezone/data/latest.dart' as timezone_data; + +part 'data.dart'; +part 'app_shell.dart'; +part 'calendar_widgets.dart'; +part 'pages.dart'; +part 'dialogs.dart'; +part 'utils.dart'; + +void main() { + timezone_data.initializeTimeZones(); + runApp(const OtthonApp()); +} diff --git a/lib/pages.dart b/lib/pages.dart new file mode 100644 index 0000000..f28d005 --- /dev/null +++ b/lib/pages.dart @@ -0,0 +1,1137 @@ +part of 'main.dart'; + +class PersonsPage extends StatelessWidget { + const PersonsPage({ + super.key, + required this.people, + required this.events, + required this.onAdd, + required this.onEdit, + required this.onDelete, + }); + + final List people; + final List events; + final VoidCallback onAdd; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return PageFrame( + eyebrow: '${people.length} csal\u00e1dtag', + title: 'Csal\u00e1dtagok', + action: FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add), + label: const Text('\u00daj csal\u00e1dtag'), + ), + child: people.isEmpty + ? const EmptyState( + icon: Icons.group_add_outlined, + title: 'M\u00e9g nincs csal\u00e1dtag', + subtitle: + 'Vegy\u00e9l fel csal\u00e1dtagokat, gyerekeket vagy b\u00e1rkit.', + ) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: people.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, i) { + final person = people[i]; + final personEvents = + events + .where((event) => event.personId == person.id) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + final nextEvent = personEvents.isEmpty + ? null + : personEvents.first; + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + FamilyAvatar(person: person), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + fontSize: 17, + ), + ), + const SizedBox(height: 4), + Text( + nextEvent == null + ? 'Nincs hozz\u00e1rendelt esem\u00e9ny' + : 'K\u00f6vetkez\u0151: ${nextEvent.title}, ${formatDate(nextEvent.date)} ${nextEvent.start}', + style: const TextStyle(color: Colors.black54), + ), + ], + ), + ), + Text( + '${personEvents.length} esem\u00e9ny', + style: const TextStyle( + color: sage, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + onSelected: (value) { + if (value == 'edit') onEdit(person); + if (value == 'delete') onDelete(person); + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'edit', + child: Text('\u00c1tnevez\u00e9s / avatar'), + ), + PopupMenuItem( + value: 'delete', + child: Text('T\u00f6rl\u00e9s'), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class StylePage extends StatelessWidget { + const StylePage({ + super.key, + required this.themeId, + required this.onThemeChanged, + }); + + final String themeId; + final ValueChanged onThemeChanged; + + @override + Widget build(BuildContext context) { + return PageFrame( + eyebrow: 'T\u00e9ma \u00e9s st\u00edlusok', + title: 'Megjelen\u00e9s', + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const Text( + 'V\u00e1lassz egy alkalmaz\u00e1st\u00e9m\u00e1t. A v\u00e1laszt\u00e1s JSON-be ment\u0151dik, \u00e9s \u00fajraind\u00edt\u00e1s ut\u00e1n is megmarad.', + style: TextStyle(color: Colors.black54, fontSize: 16), + ), + const SizedBox(height: 18), + ...appThemes.map((theme) { + final selected = theme.id == themeId; + return Card( + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: CircleAvatar(backgroundColor: theme.seed), + title: Text( + theme.name, + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + ), + ), + subtitle: Text(theme.description), + trailing: selected + ? const Icon(Icons.check_circle, color: sage) + : const Icon(Icons.circle_outlined, color: Colors.black26), + onTap: () => onThemeChanged(theme.id), + ), + ); + }), + const SizedBox(height: 18), + const Text( + 'Csal\u00e1dtag sz\u00ednek \u00e9s avatar k\u00e9szlet', + style: TextStyle( + color: ink, + fontWeight: FontWeight.w800, + fontSize: 18, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: avatarPresets + .map( + (preset) => Chip( + avatar: Text(preset.symbol), + label: Text(preset.label), + ), + ) + .toList(), + ), + ], + ), + ); + } +} + +class ShoppingPage extends StatelessWidget { + const ShoppingPage({ + super.key, + required this.items, + required this.onChanged, + required this.onAdd, + }); + + final List items; + final VoidCallback onChanged; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + final remaining = items.where((e) => !e.done).length; + return PageFrame( + eyebrow: '$remaining t\u00e9tel van h\u00e1tra', + title: 'Bev\u00e1s\u00e1rl\u00f3lista', + action: IconButton.filled(onPressed: onAdd, icon: const Icon(Icons.add)), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: mint, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + children: [ + const Icon(Icons.auto_awesome_outlined, color: sage), + const SizedBox(width: 12), + Expanded( + child: Text( + items.isEmpty + ? 'A list\u00e1d \u00fcres.' + : '${items.length - remaining} t\u00e9tel m\u00e1r a kos\u00e1rban van.', + style: const TextStyle(color: ink), + ), + ), + Text( + '${items.length - remaining}/${items.length}', + style: const TextStyle( + color: sage, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) { + final item = items[i]; + return Card( + child: CheckboxListTile( + value: item.done, + activeColor: sage, + onChanged: (value) { + item.done = value ?? false; + onChanged(); + }, + title: Text( + item.name, + style: TextStyle( + fontWeight: FontWeight.w700, + decoration: item.done + ? TextDecoration.lineThrough + : null, + color: item.done ? Colors.black38 : ink, + ), + ), + subtitle: Text('${item.amount} \u00b7 ${item.category}'), + secondary: const Icon( + Icons.drag_indicator, + color: Colors.black26, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class StockPage extends StatefulWidget { + const StockPage({ + super.key, + required this.items, + required this.onChanged, + required this.onAdd, + required this.onEdit, + required this.jarvisSettings, + }); + + final List items; + final VoidCallback onChanged; + final VoidCallback onAdd; + final ValueChanged onEdit; + final JarvisSettings jarvisSettings; + + @override + State createState() => _StockPageState(); +} + +class _StockPageState extends State { + String location = 'H\u0171t\u0151'; + + @override + Widget build(BuildContext context) { + final filtered = widget.items.where((e) => e.location == location).toList(); + return PageFrame( + eyebrow: '${widget.items.length} term\u00e9k nyilv\u00e1ntartva', + title: 'Otthoni k\u00e9szlet', + action: IconButton.filled( + onPressed: widget.onAdd, + icon: const Icon(Icons.add), + ), + child: Column( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'H\u0171t\u0151', + label: Text('H\u0171t\u0151szekr\u00e9ny'), + icon: Icon(Icons.kitchen), + ), + ButtonSegment( + value: 'Kamra', + label: Text('Sz\u00e1raz\u00e1ru'), + icon: Icon(Icons.inventory_2_outlined), + ), + ], + selected: {location}, + onSelectionChanged: (s) => setState(() => location = s.first), + ), + const SizedBox(height: 18), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: filtered.length, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (_, i) { + final item = filtered[i]; + final warning = item.expiry == 'j\u00fal. 22.'; + return Card( + child: Padding( + padding: const EdgeInsets.all(15), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: warning ? const Color(0xFFFFEBDD) : mint, + borderRadius: BorderRadius.circular(13), + ), + child: Icon( + location == 'H\u0171t\u0151' + ? Icons.egg_alt_outlined + : Icons.grain, + color: warning ? orange : sage, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: const TextStyle( + color: ink, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Lej\u00e1rat: ${item.expiry}', + style: TextStyle( + color: warning ? orange : Colors.black45, + fontWeight: warning + ? FontWeight.w700 + : FontWeight.normal, + ), + ), + ], + ), + ), + IconButton( + onPressed: item.amount > 0 + ? () { + item.amount--; + widget.onChanged(); + } + : null, + icon: const Icon(Icons.remove_circle_outline), + ), + Text( + '${item.amount} ${item.unit}', + style: const TextStyle( + color: ink, + fontWeight: FontWeight.w800, + ), + ), + IconButton( + onPressed: () { + item.amount++; + widget.onChanged(); + }, + icon: const Icon(Icons.add_circle_outline), + ), + IconButton( + tooltip: 'K\u00e9szlet szerkeszt\u00e9se', + onPressed: () => widget.onEdit(item), + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class AccountsPage extends StatefulWidget { + const AccountsPage({ + super.key, + required this.connections, + required this.cachedEvents, + required this.people, + required this.onChanged, + required this.onSyncedEventsChanged, + }); + + final List connections; + final List cachedEvents; + final List people; + final ValueChanged> onChanged; + final ValueChanged> onSyncedEventsChanged; + + @override + State createState() => _AccountsPageState(); +} + +class _AccountsPageState extends State { + final calendarPlugin = device_calendar.DeviceCalendarPlugin(); + List deviceCalendars = []; + bool loadingCalendars = false; + bool syncing = false; + String? statusMessage; + + @override + void initState() { + super.initState(); + _loadDeviceCalendars(); + } + + GoogleCalendarConnection? get connection => + widget.connections.isEmpty ? null : widget.connections.first; + + Future _loadDeviceCalendars() async { + setState(() { + loadingCalendars = true; + statusMessage = null; + }); + + try { + final permissions = await calendarPlugin.requestPermissions(); + if (permissions.isSuccess != true || permissions.data != true) { + setState(() { + loadingCalendars = false; + statusMessage = + 'Napt\u00e1r olvas\u00e1si enged\u00e9ly n\u00e9lk\u00fcl nem lehet szinkroniz\u00e1lni.'; + }); + return; + } + + final result = await calendarPlugin.retrieveCalendars(); + final calendars = + (result.data ?? const []) + .where((calendar) => calendar.id != null) + .toList() + ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); + + setState(() { + deviceCalendars = calendars; + loadingCalendars = false; + if (calendars.isEmpty) { + statusMessage = + 'Nem tal\u00e1ltam lok\u00e1lis napt\u00e1rat az eszk\u00f6z\u00f6n. Androidon ellen\u0151rizd: Be\u00e1ll\u00edt\u00e1sok > Fi\u00f3kok > Google > Fi\u00f3kszinkroniz\u00e1l\u00e1s > Napt\u00e1r legyen bekapcsolva.'; + } else { + final accountTypes = calendars + .map((calendar) => calendar.accountType) + .whereType() + .where((type) => type.trim().isNotEmpty) + .toSet() + .toList() + ..sort(); + statusMessage = + '${calendars.length} napt\u00e1rat l\u00e1tok az eszk\u00f6z\u00f6n.' + '${accountTypes.isEmpty ? '' : ' Account t\u00edpusok: ${accountTypes.join(', ')}.'}'; + } + }); + } catch (error) { + setState(() { + loadingCalendars = false; + statusMessage = + 'Nem siker\u00fclt lek\u00e9rni az eszk\u00f6z napt\u00e1rait: $error'; + }); + } + } + + Future _syncSelectedCalendars() async { + final current = connection; + if (current == null || current.enabledCalendarIds.isEmpty) { + setState( + () => + statusMessage = 'V\u00e1lassz ki legal\u00e1bb egy napt\u00e1rat.', + ); + return; + } + + setState(() { + syncing = true; + statusMessage = + 'Esem\u00e9nyek beolvas\u00e1sa a lok\u00e1lis napt\u00e1rt\u00e1rb\u00f3l\u2026'; + }); + + try { + final selectedCalendars = deviceCalendars + .where((calendar) => current.enabledCalendarIds.contains(calendar.id)) + .toList(); + final synced = []; + final syncDetails = []; + final now = DateTime.now(); + final start = DateTime( + now.year, + now.month, + now.day, + ).subtract(const Duration(days: 30)); + final end = start.add(const Duration(days: 180)); + + for (final calendar in selectedCalendars) { + final result = await calendarPlugin.retrieveEvents( + calendar.id, + device_calendar.RetrieveEventsParams(startDate: start, endDate: end), + ); + final events = result.data ?? const []; + syncDetails.add('${calendar.name ?? 'Napt\u00e1r'}: ${events.length}'); + for (final event in events) { + synced.add( + CalendarEvent.fromDeviceEvent( + event, + calendar, + personId: current.calendarPersonIds[calendar.id], + ), + ); + } + } + + synced.sort((a, b) { + final dateCompare = a.date.compareTo(b.date); + return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start); + }); + + widget.onSyncedEventsChanged(synced); + widget.onChanged([current.copyWith(lastSync: DateTime.now())]); + setState(() { + syncing = false; + statusMessage = + '${synced.length} esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.' + '${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}'; + }); + } catch (error) { + setState(() { + syncing = false; + statusMessage = 'A szinkroniz\u00e1l\u00e1s nem siker\u00fclt: $error'; + }); + } + } + + @override + Widget build(BuildContext context) { + final current = connection; + final hasConnection = current != null; + final cachedUncategorized = widget.cachedEvents + .where((event) => event.isUncategorized) + .length; + + return PageFrame( + eyebrow: 'Lok\u00e1lis napt\u00e1r szinkron', + title: 'Eszk\u00f6z napt\u00e1rak', + action: FilledButton.icon( + onPressed: () => _connectGoogle(context), + icon: const Icon(Icons.add_link), + label: Text( + hasConnection + ? 'Fi\u00f3k c\u00edmk\u00e9je' + : 'Fi\u00f3k c\u00edmke', + ), + ), + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + GoogleSyncHero( + accountEmail: current?.accountEmail, + enabledCount: current?.enabledCalendarIds.length ?? 0, + cachedCount: widget.cachedEvents.length, + uncategorizedCount: cachedUncategorized, + ), + const SizedBox(height: 22), + if (!hasConnection) + const EmptyGoogleConnectionCard() + else ...[ + const Text( + 'Eszk\u00f6z\u00f6n el\u00e9rhet\u0151 napt\u00e1rak', + style: TextStyle( + color: ink, + fontWeight: FontWeight.w800, + fontSize: 18, + ), + ), + const SizedBox(height: 10), + if (loadingCalendars) + const LinearProgressIndicator() + else + ...deviceCalendars.map( + (calendar) => DeviceCalendarToggleCard( + calendar: calendar, + enabled: current.enabledCalendarIds.contains(calendar.id), + people: widget.people, + selectedPersonId: current.calendarPersonIds[calendar.id], + onChanged: (enabled) { + final nextIds = [...current.enabledCalendarIds]; + final nextPersonIds = {...current.calendarPersonIds}; + if (enabled) { + if (!nextIds.contains(calendar.id)) { + nextIds.add(calendar.id!); + } + } else { + nextIds.remove(calendar.id); + nextPersonIds.remove(calendar.id); + } + widget.onChanged([ + current.copyWith( + enabledCalendarIds: nextIds, + calendarPersonIds: nextPersonIds, + lastSync: current.lastSync, + ), + ]); + }, + onPersonChanged: (personId) { + if (calendar.id == null) return; + final nextPersonIds = {...current.calendarPersonIds}; + if (personId == null) { + nextPersonIds.remove(calendar.id); + } else { + nextPersonIds[calendar.id!] = personId; + } + widget.onChanged([ + current.copyWith( + calendarPersonIds: nextPersonIds, + lastSync: current.lastSync, + ), + ]); + }, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: syncing ? null : _syncSelectedCalendars, + icon: syncing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + label: Text( + syncing + ? 'Szinkroniz\u00e1l\u00e1s\u2026' + : 'Szinkroniz\u00e1l\u00e1s most', + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _loadDeviceCalendars, + icon: const Icon(Icons.refresh), + label: const Text('Napt\u00e1rlista friss\u00edt\u00e9se'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () { + widget.onChanged([]); + widget.onSyncedEventsChanged([]); + }, + icon: const Icon(Icons.link_off), + label: const Text('Napt\u00e1rkapcsolat lev\u00e1laszt\u00e1sa'), + ), + ], + if (statusMessage != null) ...[ + const SizedBox(height: 14), + Text(statusMessage!, style: const TextStyle(color: Colors.black54)), + ], + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: mint, + borderRadius: BorderRadius.circular(18), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: sage), + SizedBox(width: 13), + Expanded( + child: Text( + 'Ez a szinkron egyir\u00e1ny\u00fa: az eszk\u00f6z lok\u00e1lis napt\u00e1rt\u00e1r\u00e1b\u00f3l olvas, a beolvasott esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza k\u00fcls\u0151 napt\u00e1rba. ' + 'Google, Apple/iCloud, Outlook vagy m\u00e1s napt\u00e1r akkor jelenik meg itt, ha az oper\u00e1ci\u00f3s rendszer napt\u00e1rt\u00e1r\u00e1ban el\u00e9rhet\u0151.', + style: TextStyle(color: ink, height: 1.45), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _connectGoogle(BuildContext context) async { + final email = await showDialog( + context: context, + builder: (context) => + GoogleAccountDialog(initialEmail: connection?.accountEmail ?? ''), + ); + if (email == null || email.isEmpty) return; + + widget.onChanged([ + GoogleCalendarConnection( + id: connection?.id ?? uniqueId(), + accountEmail: email, + enabledCalendarIds: connection?.enabledCalendarIds ?? const [], + lastSync: DateTime.now(), + ), + ]); + } +} + +class GoogleSyncHero extends StatelessWidget { + const GoogleSyncHero({ + super.key, + required this.accountEmail, + required this.enabledCount, + required this.cachedCount, + required this.uncategorizedCount, + }); + + final String? accountEmail; + final int enabledCount; + final int cachedCount; + final int uncategorizedCount; + + @override + Widget build(BuildContext context) { + final connected = accountEmail != null && accountEmail!.isNotEmpty; + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF18332D), Color(0xFF2F6B5B)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: sage.withValues(alpha: .22), + blurRadius: 28, + offset: const Offset(0, 16), + ), + ], + ), + child: Row( + children: [ + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .16), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.g_mobiledata_rounded, + color: Colors.white, + size: 44, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + connected + ? accountEmail! + : 'M\u00e9g nincs napt\u00e1rkapcsolat', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 20, + ), + ), + const SizedBox(height: 5), + Text( + connected + ? '$enabledCount napt\u00e1r bekapcsolva \u00b7 $cachedCount esem\u00e9ny cache-ben \u00b7 $uncategorizedCount kategoriz\u00e1latlan' + : 'Adj meg egy c\u00edmk\u00e9t, majd v\u00e1laszd ki, mely eszk\u00f6znapt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.', + style: const TextStyle(color: Colors.white70, height: 1.35), + ), + ], + ), + ), + ], + ), + ); + } +} + +class GoogleAccountDialog extends StatefulWidget { + const GoogleAccountDialog({super.key, required this.initialEmail}); + + final String initialEmail; + + @override + State createState() => _GoogleAccountDialogState(); +} + +class _GoogleAccountDialogState extends State { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController( + text: widget.initialEmail.isEmpty + ? 'csalad@gmail.com' + : widget.initialEmail, + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Napt\u00e1rkapcsolat c\u00edmk\u00e9je'), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Fi\u00f3k / kapcsolat neve', + helperText: 'Pl. csalad@gmail.com, iCloud, munkahelyi napt\u00e1r', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('M\u00e9gse'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Ment\u00e9s'), + ), + ], + ); + } +} + +class EmptyGoogleConnectionCard extends StatelessWidget { + const EmptyGoogleConnectionCard({super.key}); + + @override + Widget build(BuildContext context) { + return const Card( + child: Padding( + padding: EdgeInsets.all(20), + child: Row( + children: [ + Icon(Icons.calendar_month_outlined, color: sage), + SizedBox(width: 14), + Expanded( + child: Text( + 'Adj hozz\u00e1 egy Google fi\u00f3kot, majd v\u00e1laszd ki, mely napt\u00e1rak jelenjenek meg a helyi csal\u00e1di n\u00e9zetben.', + style: TextStyle(color: ink, height: 1.4), + ), + ), + ], + ), + ), + ); + } +} + +class GoogleCalendarToggleCard extends StatelessWidget { + const GoogleCalendarToggleCard({ + super.key, + required this.calendar, + required this.enabled, + required this.onChanged, + }); + + final GoogleCalendarOption calendar; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Card( + child: SwitchListTile( + value: enabled, + activeThumbColor: calendar.color, + onChanged: onChanged, + secondary: CircleAvatar( + backgroundColor: calendar.color.withValues(alpha: .14), + child: Icon(Icons.calendar_today, color: calendar.color, size: 19), + ), + title: Text( + calendar.name, + style: const TextStyle(color: ink, fontWeight: FontWeight.w800), + ), + subtitle: Text(calendar.description), + ), + ); + } +} + +class DeviceCalendarToggleCard extends StatelessWidget { + const DeviceCalendarToggleCard({ + super.key, + required this.calendar, + required this.enabled, + required this.people, + required this.selectedPersonId, + required this.onChanged, + required this.onPersonChanged, + }); + + final device_calendar.Calendar calendar; + final bool enabled; + final List people; + final String? selectedPersonId; + final ValueChanged onChanged; + final ValueChanged onPersonChanged; + + @override + Widget build(BuildContext context) { + final calendarColor = Color( + calendar.color ?? const Color(0xFF4285F4).toARGB32(), + ); + return Card( + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + children: [ + SwitchListTile( + value: enabled, + activeThumbColor: calendarColor, + onChanged: onChanged, + secondary: CircleAvatar( + backgroundColor: calendarColor.withValues(alpha: .14), + child: Icon( + Icons.calendar_today, + color: calendarColor, + size: 19, + ), + ), + title: Text( + calendar.name ?? 'N\u00e9vtelen napt\u00e1r', + style: const TextStyle(color: ink, fontWeight: FontWeight.w800), + ), + subtitle: Text( + [ + calendar.accountName, + calendar.accountType, + if (calendar.isReadOnly == true) 'csak olvashat\u00f3', + ] + .whereType() + .where((part) => part.isNotEmpty) + .join(' \u00b7 '), + ), + ), + if (enabled) + Padding( + padding: const EdgeInsets.fromLTRB(72, 0, 16, 0), + child: DropdownButtonFormField( + initialValue: selectedPersonId, + decoration: const InputDecoration( + labelText: 'Kihez tartozzanak az esem\u00e9nyek?', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Kategoriz\u00e1latlan'), + ), + const DropdownMenuItem( + value: everyonePersonId, + child: Text('Mindenki'), + ), + ...people.map( + (person) => DropdownMenuItem( + value: person.id, + child: Text('${person.avatar} ${person.name}'), + ), + ), + ], + onChanged: onPersonChanged, + ), + ), + ], + ), + ), + ); + } +} + +class InfoBox extends StatelessWidget { + const InfoBox({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: mint, + borderRadius: BorderRadius.circular(18), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lock_outline, color: sage), + SizedBox(width: 13), + Expanded( + child: Text( + 'A hiteles\u00edt\u00e9si adatok nem ker\u00fclnek az alkalmaz\u00e1sba. ' + 'A Google OAuth, az iCloud pedig biztons\u00e1gos CalDAV-kapcsolaton kereszt\u00fcl csatlakozik.', + style: TextStyle(color: ink, height: 1.45), + ), + ), + ], + ), + ); + } +} + +class AccountCard extends StatefulWidget { + const AccountCard({ + super.key, + required this.icon, + required this.color, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final Color color; + final String title; + final String subtitle; + + @override + State createState() => _AccountCardState(); +} + +class _AccountCardState extends State { + bool connected = false; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + CircleAvatar( + backgroundColor: widget.color.withValues(alpha: .1), + child: Icon(widget.icon, color: widget.color, size: 28), + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: const TextStyle( + color: ink, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + connected + ? 'Csatlakoztatva \u00b7 Most friss\u00edtve' + : widget.subtitle, + style: TextStyle(color: connected ? sage : Colors.black54), + ), + ], + ), + ), + OutlinedButton( + onPressed: () { + setState(() => connected = !connected); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + connected + ? '${widget.title} dem\u00f3kapcsolat l\u00e9trehozva' + : '${widget.title} lev\u00e1lasztva', + ), + ), + ); + }, + child: Text( + connected ? 'Lev\u00e1laszt\u00e1s' : 'Kapcsol\u00f3d\u00e1s', + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..58ddf69 --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,98 @@ +part of 'main.dart'; + +String formatDate(DateTime date) { + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return '${date.year}.$month.$day'; +} + +String formatTime(DateTime date) { + final hour = date.hour.toString().padLeft(2, '0'); + final minute = date.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; +} + +String formatTimeWithSeconds(DateTime date) { + final hour = date.hour.toString().padLeft(2, '0'); + final minute = date.minute.toString().padLeft(2, '0'); + final second = date.second.toString().padLeft(2, '0'); + return '$hour:$minute:$second'; +} + +String formatShortDate(DateTime date) { + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return '$month.$day'; +} + +int isoWeekNumber(DateTime date) { + final thursday = date.add(Duration(days: 4 - date.weekday)); + final firstThursday = DateTime(thursday.year, 1, 4); + final firstWeekStart = firstThursday.subtract( + Duration(days: firstThursday.weekday - 1), + ); + return thursday.difference(firstWeekStart).inDays ~/ 7 + 1; +} + +DateTime startOfIsoWeek(DateTime date) { + final normalized = DateTime(date.year, date.month, date.day); + return normalized.subtract(Duration(days: normalized.weekday - 1)); +} + +DateTime startOfDay(DateTime date) => DateTime(date.year, date.month, date.day); + +bool isSameDate(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; +} + +DateTime? parseDate(String value) { + final normalized = value.trim().replaceAll('-', '.'); + final parts = normalized.split('.').where((part) => part.isNotEmpty).toList(); + if (parts.length != 3) return null; + + final year = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final day = int.tryParse(parts[2]); + if (year == null || month == null || day == null) return null; + + return DateTime(year, month, day); +} + +String uniqueId() => DateTime.now().microsecondsSinceEpoch.toString(); + +String normalizeJarvisUrl(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return JarvisSettings.defaults().apiUrl; + final withScheme = + trimmed.startsWith('http://') || trimmed.startsWith('https://') + ? trimmed + : 'http://$trimmed'; + return withScheme.endsWith('/') + ? withScheme.substring(0, withScheme.length - 1) + : withScheme; +} + +String weatherDescription(int? code) { + if (code == null) return 'id\u0151j\u00e1r\u00e1s'; + if (code == 0) return 'der\u00fclt'; + if ([1, 2, 3].contains(code)) return 'felh\u0151s'; + if ([45, 48].contains(code)) return 'k\u00f6d'; + if ([51, 53, 55, 56, 57].contains(code)) return 'szit\u00e1l\u00e1s'; + if ([61, 63, 65, 66, 67, 80, 81, 82].contains(code)) return 'es\u0151'; + if ([71, 73, 75, 77, 85, 86].contains(code)) return 'havaz\u00e1s'; + if ([95, 96, 99].contains(code)) return 'zivatar'; + return 'v\u00e1ltoz\u00e9kony'; +} + +IconData weatherIcon(int? code) { + if (code == null) return Icons.thermostat_outlined; + if (code == 0) return Icons.wb_sunny_outlined; + if ([1, 2, 3].contains(code)) return Icons.cloud_outlined; + if ([45, 48].contains(code)) return Icons.foggy; + if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].contains(code)) { + return Icons.water_drop_outlined; + } + if ([71, 73, 75, 77, 85, 86].contains(code)) return Icons.ac_unit; + if ([95, 96, 99].contains(code)) return Icons.thunderstorm_outlined; + return Icons.cloud_queue; +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..9e5dad8 --- /dev/null +++ b/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 "otthon_naptar") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "hu.otthonnaptar.otthon_naptar") + +# 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/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/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/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/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/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..be1ee3e --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +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/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/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/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/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/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..674bf97 --- /dev/null +++ b/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, "otthon_naptar"); + 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, "otthon_naptar"); + } + + 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/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/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/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..8037017 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,18 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import geocoding_darwin +import geolocator_apple +import mobile_scanner +import package_info_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3e29379 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* otthon_naptar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "otthon_naptar.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* otthon_naptar.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* otthon_naptar.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/otthon_naptar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/otthon_naptar"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/otthon_naptar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/otthon_naptar"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/otthon_naptar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/otthon_naptar"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..460ac58 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..9f17541 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = otthon_naptar + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = hu.otthonnaptar.otthonNaptar + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 hu.otthonnaptar. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..55eefda --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,730 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + 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" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + url: "https://pub.dev" + source: hosted + version: "3.1.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + device_calendar: + dependency: "direct main" + description: + name: device_calendar + sha256: "683fb93ec302b6a65c0ce57df40ff9dcc2404f59c67a2f8b93e59318c8a0a225" + url: "https://pub.dev" + source: hosted + version: "4.3.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + 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" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "40f845e0a5505d4b47da991057f2ab25cd1c68bc4570255c7d4cfbb03a952063" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "17e88a69bb0e1ae44597d70df2d2fbdb2f503672cf665421a16ab56ab97e4ead" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + geocoding_darwin: + dependency: transitive + description: + name: geocoding_darwin + sha256: ac9c85dc6fbb1afa6fea9be0c174a782132b19c01c0e44c4204d7fe8974ac96b + url: "https://pub.dev" + source: hosted + version: "1.0.2" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "5164b8871705af7573698b3aa0746337faf818d72b488988741bcc7aeedd2f6e" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f + url: "https://pub.dev" + source: hosted + version: "14.0.3" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + 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" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + 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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce + url: "https://pub.dev" + source: hosted + version: "7.4.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + pigeon: + dependency: transitive + description: + name: pigeon + sha256: "04cfefc8add8b47ddf9ccac8b92bb4edeb67c87f185c623ba0db118ac99334ad" + url: "https://pub.dev" + source: hosted + version: "26.3.4" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + 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" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + 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: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + 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: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..a8d15eb --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,100 @@ +name: otthon_naptar +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.12.2 + +# 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 + path_provider: ^2.1.6 + device_calendar: ^4.3.3 + timezone: ^0.9.4 + http: ^1.6.0 + geolocator: ^14.0.3 + geocoding: ^5.0.0 + mobile_scanner: ^7.4.0 + +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 + + assets: + - assets/branding/jarvis-api-logo.png + - assets/branding/abdai-design-logo.png + + # 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/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..8f36368 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:otthon_naptar/main.dart'; + +void main() { + testWidgets( + 'a csal\u00e1di napt\u00e1r f\u0151 n\u00e9zete bet\u00f6lt\u0151dik', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(const OtthonApp()); + + expect(find.byType(CalendarPage), findsOneWidget); + expect(find.byType(PageFrame), findsOneWidget); + }, + ); + + testWidgets('a csal\u00e1dtagok n\u00e9zet el\u00e9rhet\u0151', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(const OtthonApp()); + await tester.tap(find.byIcon(Icons.group_outlined)); + await tester.pumpAndSettle(); + + expect(find.byType(PersonsPage), findsOneWidget); + }); + + testWidgets( + 'a st\u00edlus n\u00e9zet \u00e9s avatar k\u00e9szlet el\u00e9rhet\u0151', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(const OtthonApp()); + await tester.tap(find.byIcon(Icons.palette_outlined)); + await tester.pumpAndSettle(); + + expect(find.byType(StylePage), findsOneWidget); + }, + ); + + testWidgets( + 'az eszk\u00f6z napt\u00e1rkapcsolatok oldala el\u00e9rhet\u0151', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(const OtthonApp()); + await tester.tap(find.byIcon(Icons.sync_rounded)); + await tester.pumpAndSettle(); + + expect(find.byType(AccountsPage), findsOneWidget); + }, + ); + + test( + 'a csal\u00e1dtag k\u00f6vetkez\u0151 esem\u00e9nyei csak m\u00e1t\u00f3l indulnak', + () { + final now = DateTime(2026, 7, 19, 15); + final events = [ + CalendarEvent( + 'Tegnapi program', + '10:00', + '11:00', + Colors.red, + date: DateTime(2026, 7, 18), + personId: 'balint', + ), + CalendarEvent( + 'Mai program', + '09:00', + '10:00', + Colors.green, + date: DateTime(2026, 7, 19), + personId: 'balint', + ), + CalendarEvent( + 'Holnapi program', + '08:00', + '09:00', + Colors.blue, + date: DateTime(2026, 7, 20), + personId: 'balint', + ), + ]; + + final nextEvents = findNextEventsForPerson('balint', events, 3, now: now); + + expect(nextEvents.map((event) => event.title), [ + 'Mai program', + 'Holnapi program', + ]); + }, + ); + + test( + 'a mindenkihez tartoz\u00f3 esem\u00e9ny minden csal\u00e1dtagn\u00e1l megjelenik', + () { + final now = DateTime(2026, 7, 19, 15); + final events = [ + CalendarEvent( + 'Csal\u00e1di program', + '17:00', + '18:00', + Colors.green, + date: DateTime(2026, 7, 20), + personId: everyonePersonId, + ), + ]; + + final balintEvents = findNextEventsForPerson( + 'balint', + events, + 3, + now: now, + ); + final emiliaEvents = findNextEventsForPerson( + 'emilia', + events, + 3, + now: now, + ); + + expect(balintEvents.single.title, 'Csal\u00e1di program'); + expect(emiliaEvents.single.title, 'Csal\u00e1di program'); + }, + ); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e0d1c0b --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + otthon_naptar + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..157bcaa --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "otthon_naptar", + "short_name": "otthon_naptar", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..ea2eaa4 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(otthon_naptar 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 "otthon_naptar") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + 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() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# 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_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +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) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +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. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +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. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# 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/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app 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. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1ece8f2 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/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 RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a49b9f6 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + geolocator_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows 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}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +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} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# 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 build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..bdc3ab7 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "hu.otthonnaptar" "\0" + VALUE "FileDescription", "otthon_naptar" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "otthon_naptar" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 hu.otthonnaptar. All rights reserved." "\0" + VALUE "OriginalFilename", "otthon_naptar.exe" "\0" + VALUE "ProductName", "otthon_naptar" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..36e2b29 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"otthon_naptar", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_