commit 833c68a1892843504baf8e56e2a1c62440ddf4c7 Author: yc Date: Mon Jul 27 14:42:23 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..89db5de --- /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: "ff37bef603469fb030f2b72995ab929ccfc227f0" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: android + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: ios + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: linux + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: macos + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: web + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: windows + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + + # 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4468b12 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,161 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +NodeMaster Manager — a Flutter GUI client for the NodeMaster API, a Go/Beego +backend that lives in the sibling repo `../nodemaster` (i.e. +`/home/yves/Documents/code/PROJECTS/nodemaster/nodemaster` if this repo is +checked out at `/home/yves/Documents/code/PROJECTS/nodemaster/manager`). +NodeMaster manages a homelab-style host: docker-compose "services" (start/ +stop/backup), restic/rsync backup targets, OS update history, and a fleet of +other NodeMaster nodes. This app is the multi-platform (Linux/macOS/Windows/ +web/Android/iOS) desktop-first UI over that API's REST endpoints under `/v1`. + +**The Go API is the source of truth for wire shapes.** Every Dart model in +`lib/core/models/` mirrors a Go struct in `../nodemaster/models/`, field for +field, including `@JsonKey` renames for snake_case. When the API changes, +re-read the actual Go source (`models/*.go`, `controllers/*.go`) rather than +trusting existing Dart models or prior assumptions — they can drift, and +swagger docs in that repo have been observed stale relative to the +controllers. Neither repo currently has authentication; the API is meant to +sit behind a VPN/reverse proxy (see the `NoAuthCallout` banner on Settings). + +## Commands + +```bash +flutter pub get # install deps +dart run build_runner build --delete-conflicting-outputs # regenerate *.freezed.dart / *.g.dart +dart run build_runner watch --delete-conflicting-outputs # regenerate on save, for long edit sessions +flutter analyze # static analysis (must be clean) +flutter test # full test suite +flutter test test/core/models/service_test.dart # single test file +flutter test --plain-name "parses a fully-populated service" # single test by name +flutter run -d chrome # run as web app +flutter run -d linux # run as Linux desktop app +flutter build linux --debug # build the Linux bundle (build/linux/x64/debug/bundle/manager) +``` + +**Any change to a `@freezed` or `@riverpod`/`@Riverpod` class requires a +`build_runner build` afterward** — the `.freezed.dart`/`.g.dart` companion +files are committed generated code, not build artifacts recreated from +scratch each time, so a source edit without regeneration leaves the app +compiling against stale generated code (or plain compile errors if fields +changed). If a diagnostics pass immediately after a `build_runner` run shows +errors referencing symbols you didn't touch, it's very likely IDE/analyzer +staleness from the file swap — rerun `flutter analyze` fresh before +concluding something is actually broken. + +To run the app against a live API, start the sibling Go repo first +(`cd ../nodemaster && go run main.go`, default port `8080`, config at +`~/.nodemaster.conf`), then add a connection in Settings pointing at it (or a +pre-seeded `SharedPreferences` `connections` value in tests — see +`test/widget_test.dart`). + +## Architecture + +### Layering: `core/network` → `data/repositories` → `features/*/providers` → screens + +- **`lib/core/network/node_master_api_client.dart`** — one `NodeMasterApiClient` + class, one method per REST endpoint, thin (dio call in, typed model out). + Every method funnels through `_guard`, which enforces a hard 20s request + budget (`Future.timeout`, not just Dio's own timeouts — needed because + Dio's web/browser adapter has no connect-phase timeout hook) and maps every + failure mode to a typed `ApiException` via `dio_error_mapper.dart`. This is + the *only* place that touches `Dio`/`DioException` directly. +- **`lib/data/repositories/*_repository.dart`** — one repository per REST + resource group (`NodeRepository`, `ServicesRepository`, `NodesRepository`), + each a thin pass-through over `NodeMasterApiClient`. Each repository has a + paired `@riverpod` provider (e.g. `nodeRepositoryProvider`) that returns + **null when there's no active connection** — this null-propagation pattern + is used consistently up through `apiClientProvider` so every downstream + provider gets the same "no connection" signal without re-deriving it. +- **`lib/features//`** — one folder per nav destination (`dashboard`, + `services`, `backups`, `fleet`, `updates`, `settings`), each with a + top-level `_screen.dart`, a `widgets/` subfolder for + feature-local dialogs/rows, and a `providers/` subfolder where the screen + needs bespoke async state beyond a bare repository call. + +### State management: Riverpod 3 with code generation + +All providers use `@riverpod`/`@Riverpod(...)` annotations + generated `.g.dart` +companions (not the older manual `Provider`/`StateNotifierProvider` syntax). +`main.dart` disables Riverpod 3's container-wide auto-retry +(`retry: (retryCount, error) => null`) because the app already has its own +explicit refresh model (manual pull-to-refresh + `PollingMixin` for two +screens) — a blanket retry-with-backoff would silently fight that. Widget +tests must pass the same `retry: null` override or a deliberately-unreachable +test connection won't settle into `AsyncError` deterministically. + +`lib/core/polling/polling_async_notifier.dart`'s `PollingMixin` is mixed +into an `AutoDisposeAsyncNotifier` to re-fetch on a timer while the provider +has a listener (used by Dashboard's summary and Fleet's aggregated view — +the API has no push/websocket, so this is a deliberate "poll while visible" +model, not a live stream). It never emits an intermediate `AsyncLoading` on +refresh ticks, so a background poll never flashes the UI back to a spinner. +Other list-backed screens (Services, node backup config, updates) are +manual-refresh only, on purpose — a silent background refresh could yank +state out from under an in-progress edit dialog. + +### Connections (multi-host support) + +`lib/core/connections/` models a list of saved API endpoints +(`Connection { id, name, baseUrl }`), persisted as plain JSON in +`SharedPreferences` via `ConnectionStorage` (explicitly *not* a secret store +— the API has no auth, so a saved base URL isn't sensitive by itself). +`activeConnectionProvider` (in `connection_providers.dart`) is the single +source everything downstream derives from: `apiClientProvider` rebuilds +(never mutates) a fresh `Dio`/`NodeMasterApiClient` whenever it changes, which +cascades through every repository and feature provider automatically. + +### Routing + +`lib/core/routing/app_router.dart` uses `go_router` with a single +`@Riverpod(keepAlive: true)` `GoRouter`. A `_ConnectionRefreshListenable` +bridges `activeConnectionProvider` into go_router's `refreshListenable` so +removing the active connection redirects to Settings immediately rather than +waiting for the next navigation. All feature screens sit inside one +`ShellRoute` wrapping `NavRailShell` (the persistent side nav). + +### Models (`lib/core/models/`) + +`@freezed` sealed classes + `json_serializable` (`explicit_to_json: true` in +`build.yaml`), one file per Go struct, with `part 'x.freezed.dart'` / +`part 'x.g.dart'`. Field renames to match the API's snake_case use +`@JsonKey(name: '...')`. Write-only fields the API never echoes back (e.g. a +password) should default to `null`/omitted-on-null (`includeIfNull: false`) +rather than round-tripping a value the server will never actually send. +`lib/core/models/models.dart` is the barrel export — import that, not +individual model files, from feature code. + +### Theming + +`lib/core/theme/` defines two custom `ThemeExtension`s — `StatusColors` +(good/warning/serious/critical, reserved exclusively for status semantics, +never reused as a brand color) and `AppDataStyles` (monospace styles for +numeric/data display). Access via the `BuildContext` extension in +`theme_x.dart`: `context.status.good`, `context.dataStyles.dataMono`, +`context.colors`, `context.text` — prefer these over raw +`Theme.of(context).extension<...>()!` calls. `StatusPill` (`core/widgets/`) +is the shared good/warning/serious/critical/neutral badge — reuse it rather +than building ad hoc colored chips. + +### Error handling + +`ApiException` (`core/network/api_exception.dart`) is a sealed/freezed-style +type with variants for timeout, network, cancelled, server (with status code ++ message), parse, and unknown. UI code catches `ApiException` and reads +`.userMessage` for display; it should never need to inspect `DioException` or +raw status codes directly. + +## Testing conventions + +Widget tests spin up a real `ProviderScope` with `retry: null` and +`SharedPreferences.setMockInitialValues(...)` to seed connection state (see +`test/widget_test.dart`) rather than mocking providers individually — this +exercises the actual router-redirect and provider-null-propagation logic. A +deliberately unreachable connection (`http://127.0.0.1:1`) is used to get a +fast, deterministic connection-refused error without depending on real +network access. `mocktail`/`fake_async` are available as dev dependencies for +narrower unit tests (e.g. `dio_error_mapper_test.dart`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a5c8cf --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# manager + +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..bd28abc --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,36 @@ +# 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 + +analyzer: + errors: + # False positive on every freezed class that puts @JsonKey on a factory + # constructor parameter (the standard, documented pattern) — freezed + # forwards the annotation to the generated field correctly; the analyzer + # just doesn't see that at the constructor-parameter site. + invalid_annotation_target: ignore + +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..51518a9 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.manager" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.manager" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/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..43af087 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/manager/MainActivity.kt b/android/app/src/main/kotlin/com/example/manager/MainActivity.kt new file mode 100644 index 0000000..b07acfa --- /dev/null +++ b/android/app/src/main/kotlin/com/example/manager/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.manager + +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..db77bb4 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..17987b7 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..09d4391 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..d5f1c8d 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..4d6372e 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..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /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-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /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 "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/fonts/Inter-OFL.txt b/assets/fonts/Inter-OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/assets/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Inter-Variable.ttf b/assets/fonts/Inter-Variable.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/assets/fonts/Inter-Variable.ttf differ diff --git a/assets/fonts/JetBrainsMono-OFL.txt b/assets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..821a3da --- /dev/null +++ b/assets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/JetBrainsMono-Variable.ttf b/assets/fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/assets/fonts/JetBrainsMono-Variable.ttf differ diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..7cb5117 --- /dev/null +++ b/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + json_serializable: + options: + explicit_to_json: true diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: 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..459649a --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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 */; }; + 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 = ""; }; + 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 = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 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; + 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; + 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 = com.example.manager; + 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 = com.example.manager.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 = com.example.manager.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 = com.example.manager.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 = com.example.manager; + 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 = com.example.manager; + 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 */ + }; + 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..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..1aa6c62 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Manager + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + manager + 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.dart b/lib/app.dart new file mode 100644 index 0000000..ed47e7c --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/routing/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'core/theme/theme_mode_provider.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeControllerProvider); + + return MaterialApp.router( + title: 'NodeMaster Manager', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: themeMode, + routerConfig: router, + ); + } +} diff --git a/lib/core/connections/connection.dart b/lib/core/connections/connection.dart new file mode 100644 index 0000000..cd05e34 --- /dev/null +++ b/lib/core/connections/connection.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'connection.freezed.dart'; +part 'connection.g.dart'; + +/// A saved NodeMaster API endpoint. Client-side only — has no server-side +/// counterpart. `baseUrl` excludes the `/v1` suffix (added by +/// [NodeMasterApiClient]) and any trailing slash, e.g. +/// `https://edge-01.local:8080`. +@freezed +sealed class Connection with _$Connection { + const factory Connection({ + required String id, + required String name, + required String baseUrl, + }) = _Connection; + + factory Connection.fromJson(Map json) => _$ConnectionFromJson(json); +} diff --git a/lib/core/connections/connection.freezed.dart b/lib/core/connections/connection.freezed.dart new file mode 100644 index 0000000..27f69d1 --- /dev/null +++ b/lib/core/connections/connection.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connection.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Connection { + + String get id; String get name; String get baseUrl; +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConnectionCopyWith get copyWith => _$ConnectionCopyWithImpl(this as Connection, _$identity); + + /// Serializes this Connection to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Connection&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl); + +@override +String toString() { + return 'Connection(id: $id, name: $name, baseUrl: $baseUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $ConnectionCopyWith<$Res> { + factory $ConnectionCopyWith(Connection value, $Res Function(Connection) _then) = _$ConnectionCopyWithImpl; +@useResult +$Res call({ + String id, String name, String baseUrl +}); + + + + +} +/// @nodoc +class _$ConnectionCopyWithImpl<$Res> + implements $ConnectionCopyWith<$Res> { + _$ConnectionCopyWithImpl(this._self, this._then); + + final Connection _self; + final $Res Function(Connection) _then; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Connection]. +extension ConnectionPatterns on Connection { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Connection value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Connection value) $default,){ +final _that = this; +switch (_that) { +case _Connection(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Connection value)? $default,){ +final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String baseUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String baseUrl) $default,) {final _that = this; +switch (_that) { +case _Connection(): +return $default(_that.id,_that.name,_that.baseUrl);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String baseUrl)? $default,) {final _that = this; +switch (_that) { +case _Connection() when $default != null: +return $default(_that.id,_that.name,_that.baseUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Connection implements Connection { + const _Connection({required this.id, required this.name, required this.baseUrl}); + factory _Connection.fromJson(Map json) => _$ConnectionFromJson(json); + +@override final String id; +@override final String name; +@override final String baseUrl; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ConnectionCopyWith<_Connection> get copyWith => __$ConnectionCopyWithImpl<_Connection>(this, _$identity); + +@override +Map toJson() { + return _$ConnectionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Connection&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.baseUrl, baseUrl) || other.baseUrl == baseUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,baseUrl); + +@override +String toString() { + return 'Connection(id: $id, name: $name, baseUrl: $baseUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$ConnectionCopyWith<$Res> implements $ConnectionCopyWith<$Res> { + factory _$ConnectionCopyWith(_Connection value, $Res Function(_Connection) _then) = __$ConnectionCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String baseUrl +}); + + + + +} +/// @nodoc +class __$ConnectionCopyWithImpl<$Res> + implements _$ConnectionCopyWith<$Res> { + __$ConnectionCopyWithImpl(this._self, this._then); + + final _Connection _self; + final $Res Function(_Connection) _then; + +/// Create a copy of Connection +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? baseUrl = null,}) { + return _then(_Connection( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,baseUrl: null == baseUrl ? _self.baseUrl : baseUrl // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/connections/connection.g.dart b/lib/core/connections/connection.g.dart new file mode 100644 index 0000000..d058cd6 --- /dev/null +++ b/lib/core/connections/connection.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connection.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Connection _$ConnectionFromJson(Map json) => _Connection( + id: json['id'] as String, + name: json['name'] as String, + baseUrl: json['baseUrl'] as String, +); + +Map _$ConnectionToJson(_Connection instance) => + { + 'id': instance.id, + 'name': instance.name, + 'baseUrl': instance.baseUrl, + }; diff --git a/lib/core/connections/connection_providers.dart b/lib/core/connections/connection_providers.dart new file mode 100644 index 0000000..b3c0fb4 --- /dev/null +++ b/lib/core/connections/connection_providers.dart @@ -0,0 +1,75 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../utils/shared_preferences_provider.dart'; +import 'connection.dart'; +import 'connection_storage.dart'; + +part 'connection_providers.g.dart'; + +@Riverpod(keepAlive: true) +ConnectionStorage connectionStorage(Ref ref) { + return ConnectionStorage(ref.watch(sharedPreferencesProvider)); +} + +/// The full list of saved connections. Mutations persist immediately. +@Riverpod(keepAlive: true) +class SavedConnections extends _$SavedConnections { + @override + List build() => ref.watch(connectionStorageProvider).readConnections(); + + Future add(Connection connection) async { + state = [...state, connection]; + await ref.read(connectionStorageProvider).writeConnections(state); + } + + Future update(Connection connection) async { + state = [ + for (final c in state) + if (c.id == connection.id) connection else c, + ]; + await ref.read(connectionStorageProvider).writeConnections(state); + } + + Future remove(String id) async { + state = state.where((c) => c.id != id).toList(growable: false); + await ref.read(connectionStorageProvider).writeConnections(state); + final activeId = ref.read(activeConnectionIdProvider); + if (activeId == id) { + await ref.read(activeConnectionIdProvider.notifier).setActive( + state.isEmpty ? null : state.first.id, + ); + } + } +} + +/// The id of the currently active connection, or null if none is selected +/// (e.g. first launch with nothing configured yet). +@Riverpod(keepAlive: true) +class ActiveConnectionId extends _$ActiveConnectionId { + @override + String? build() { + final stored = ref.watch(connectionStorageProvider).readActiveConnectionId(); + final connections = ref.watch(savedConnectionsProvider); + if (stored != null && connections.any((c) => c.id == stored)) return stored; + return connections.isEmpty ? null : connections.first.id; + } + + Future setActive(String? id) async { + state = id; + await ref.read(connectionStorageProvider).writeActiveConnectionId(id); + } +} + +/// The currently active [Connection], or null if none is configured. +/// Everything in `core/network` derives from this — switching it rebuilds +/// the API client and, transitively, every provider that watches it. +@riverpod +Connection? activeConnection(Ref ref) { + final id = ref.watch(activeConnectionIdProvider); + if (id == null) return null; + final connections = ref.watch(savedConnectionsProvider); + for (final c in connections) { + if (c.id == id) return c; + } + return null; +} diff --git a/lib/core/connections/connection_providers.g.dart b/lib/core/connections/connection_providers.g.dart new file mode 100644 index 0000000..2a6364b --- /dev/null +++ b/lib/core/connections/connection_providers.g.dart @@ -0,0 +1,232 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connection_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(connectionStorage) +const connectionStorageProvider = ConnectionStorageProvider._(); + +final class ConnectionStorageProvider + extends + $FunctionalProvider< + ConnectionStorage, + ConnectionStorage, + ConnectionStorage + > + with $Provider { + const ConnectionStorageProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'connectionStorageProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$connectionStorageHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ConnectionStorage create(Ref ref) { + return connectionStorage(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ConnectionStorage value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$connectionStorageHash() => r'0faf909e3978260785f2f8ce4bc41f101e27ad66'; + +/// The full list of saved connections. Mutations persist immediately. + +@ProviderFor(SavedConnections) +const savedConnectionsProvider = SavedConnectionsProvider._(); + +/// The full list of saved connections. Mutations persist immediately. +final class SavedConnectionsProvider + extends $NotifierProvider> { + /// The full list of saved connections. Mutations persist immediately. + const SavedConnectionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'savedConnectionsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$savedConnectionsHash(); + + @$internal + @override + SavedConnections create() => SavedConnections(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$savedConnectionsHash() => r'2595280b9ef1f5835ecce70b791a901a51aaf8e8'; + +/// The full list of saved connections. Mutations persist immediately. + +abstract class _$SavedConnections extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// The id of the currently active connection, or null if none is selected +/// (e.g. first launch with nothing configured yet). + +@ProviderFor(ActiveConnectionId) +const activeConnectionIdProvider = ActiveConnectionIdProvider._(); + +/// The id of the currently active connection, or null if none is selected +/// (e.g. first launch with nothing configured yet). +final class ActiveConnectionIdProvider + extends $NotifierProvider { + /// The id of the currently active connection, or null if none is selected + /// (e.g. first launch with nothing configured yet). + const ActiveConnectionIdProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'activeConnectionIdProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$activeConnectionIdHash(); + + @$internal + @override + ActiveConnectionId create() => ActiveConnectionId(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$activeConnectionIdHash() => + r'ac8b7d48bc04e6f9245b23c83a15e11a539e1fc8'; + +/// The id of the currently active connection, or null if none is selected +/// (e.g. first launch with nothing configured yet). + +abstract class _$ActiveConnectionId extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// The currently active [Connection], or null if none is configured. +/// Everything in `core/network` derives from this — switching it rebuilds +/// the API client and, transitively, every provider that watches it. + +@ProviderFor(activeConnection) +const activeConnectionProvider = ActiveConnectionProvider._(); + +/// The currently active [Connection], or null if none is configured. +/// Everything in `core/network` derives from this — switching it rebuilds +/// the API client and, transitively, every provider that watches it. + +final class ActiveConnectionProvider + extends $FunctionalProvider + with $Provider { + /// The currently active [Connection], or null if none is configured. + /// Everything in `core/network` derives from this — switching it rebuilds + /// the API client and, transitively, every provider that watches it. + const ActiveConnectionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'activeConnectionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$activeConnectionHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Connection? create(Ref ref) { + return activeConnection(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Connection? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$activeConnectionHash() => r'544cf6f795b35636cbfc497e6379a84b1e8f5b88'; diff --git a/lib/core/connections/connection_storage.dart b/lib/core/connections/connection_storage.dart new file mode 100644 index 0000000..41b47e7 --- /dev/null +++ b/lib/core/connections/connection_storage.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'connection.dart'; + +const _connectionsKey = 'connections'; +const _activeConnectionIdKey = 'active_connection_id'; + +/// Reads/writes the saved connection list and active-connection id to +/// [SharedPreferences] as a plain JSON-encoded array. Not a secret store — +/// the API has no auth, so there's nothing sensitive in a saved base URL. +class ConnectionStorage { + const ConnectionStorage(this._prefs); + + final SharedPreferences _prefs; + + List readConnections() { + final raw = _prefs.getString(_connectionsKey); + if (raw == null || raw.isEmpty) return const []; + final decoded = jsonDecode(raw) as List; + return decoded + .map((e) => Connection.fromJson(e as Map)) + .toList(growable: false); + } + + Future writeConnections(List connections) async { + final encoded = jsonEncode(connections.map((c) => c.toJson()).toList()); + await _prefs.setString(_connectionsKey, encoded); + } + + String? readActiveConnectionId() => _prefs.getString(_activeConnectionIdKey); + + Future writeActiveConnectionId(String? id) async { + if (id == null) { + await _prefs.remove(_activeConnectionIdKey); + } else { + await _prefs.setString(_activeConnectionIdKey, id); + } + } +} diff --git a/lib/core/models/aggregated_node_status.dart b/lib/core/models/aggregated_node_status.dart new file mode 100644 index 0000000..cb8ec36 --- /dev/null +++ b/lib/core/models/aggregated_node_status.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'remote_node.dart'; +import 'service.dart'; + +part 'aggregated_node_status.freezed.dart'; +part 'aggregated_node_status.g.dart'; + +/// Mirrors the anonymous `nodeServices` struct returned by +/// `GET /nodes/aggregated`: `{node, services?, error?}`. `error` is the +/// *local* API reporting that fetching *this remote* failed (e.g. +/// unreachable) — a per-entry condition, distinct from the outer call +/// itself failing. Render `error` inline per-card; never conflate it with +/// an [ApiException] on the aggregated fetch as a whole. +@freezed +sealed class AggregatedNodeStatus with _$AggregatedNodeStatus { + const factory AggregatedNodeStatus({ + required RemoteNode node, + @Default([]) List services, + String? error, + }) = _AggregatedNodeStatus; + + factory AggregatedNodeStatus.fromJson(Map json) => + _$AggregatedNodeStatusFromJson(json); +} diff --git a/lib/core/models/aggregated_node_status.freezed.dart b/lib/core/models/aggregated_node_status.freezed.dart new file mode 100644 index 0000000..f5feba3 --- /dev/null +++ b/lib/core/models/aggregated_node_status.freezed.dart @@ -0,0 +1,301 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'aggregated_node_status.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AggregatedNodeStatus { + + RemoteNode get node; List get services; String? get error; +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AggregatedNodeStatusCopyWith get copyWith => _$AggregatedNodeStatusCopyWithImpl(this as AggregatedNodeStatus, _$identity); + + /// Serializes this AggregatedNodeStatus to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AggregatedNodeStatus&&(identical(other.node, node) || other.node == node)&&const DeepCollectionEquality().equals(other.services, services)&&(identical(other.error, error) || other.error == error)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,node,const DeepCollectionEquality().hash(services),error); + +@override +String toString() { + return 'AggregatedNodeStatus(node: $node, services: $services, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class $AggregatedNodeStatusCopyWith<$Res> { + factory $AggregatedNodeStatusCopyWith(AggregatedNodeStatus value, $Res Function(AggregatedNodeStatus) _then) = _$AggregatedNodeStatusCopyWithImpl; +@useResult +$Res call({ + RemoteNode node, List services, String? error +}); + + +$RemoteNodeCopyWith<$Res> get node; + +} +/// @nodoc +class _$AggregatedNodeStatusCopyWithImpl<$Res> + implements $AggregatedNodeStatusCopyWith<$Res> { + _$AggregatedNodeStatusCopyWithImpl(this._self, this._then); + + final AggregatedNodeStatus _self; + final $Res Function(AggregatedNodeStatus) _then; + +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? node = null,Object? services = null,Object? error = freezed,}) { + return _then(_self.copyWith( +node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable +as RemoteNode,services: null == services ? _self.services : services // ignore: cast_nullable_to_non_nullable +as List,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RemoteNodeCopyWith<$Res> get node { + + return $RemoteNodeCopyWith<$Res>(_self.node, (value) { + return _then(_self.copyWith(node: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [AggregatedNodeStatus]. +extension AggregatedNodeStatusPatterns on AggregatedNodeStatus { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AggregatedNodeStatus value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AggregatedNodeStatus() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AggregatedNodeStatus value) $default,){ +final _that = this; +switch (_that) { +case _AggregatedNodeStatus(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AggregatedNodeStatus value)? $default,){ +final _that = this; +switch (_that) { +case _AggregatedNodeStatus() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( RemoteNode node, List services, String? error)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AggregatedNodeStatus() when $default != null: +return $default(_that.node,_that.services,_that.error);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( RemoteNode node, List services, String? error) $default,) {final _that = this; +switch (_that) { +case _AggregatedNodeStatus(): +return $default(_that.node,_that.services,_that.error);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( RemoteNode node, List services, String? error)? $default,) {final _that = this; +switch (_that) { +case _AggregatedNodeStatus() when $default != null: +return $default(_that.node,_that.services,_that.error);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AggregatedNodeStatus implements AggregatedNodeStatus { + const _AggregatedNodeStatus({required this.node, final List services = const [], this.error}): _services = services; + factory _AggregatedNodeStatus.fromJson(Map json) => _$AggregatedNodeStatusFromJson(json); + +@override final RemoteNode node; + final List _services; +@override@JsonKey() List get services { + if (_services is EqualUnmodifiableListView) return _services; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_services); +} + +@override final String? error; + +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AggregatedNodeStatusCopyWith<_AggregatedNodeStatus> get copyWith => __$AggregatedNodeStatusCopyWithImpl<_AggregatedNodeStatus>(this, _$identity); + +@override +Map toJson() { + return _$AggregatedNodeStatusToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AggregatedNodeStatus&&(identical(other.node, node) || other.node == node)&&const DeepCollectionEquality().equals(other._services, _services)&&(identical(other.error, error) || other.error == error)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,node,const DeepCollectionEquality().hash(_services),error); + +@override +String toString() { + return 'AggregatedNodeStatus(node: $node, services: $services, error: $error)'; +} + + +} + +/// @nodoc +abstract mixin class _$AggregatedNodeStatusCopyWith<$Res> implements $AggregatedNodeStatusCopyWith<$Res> { + factory _$AggregatedNodeStatusCopyWith(_AggregatedNodeStatus value, $Res Function(_AggregatedNodeStatus) _then) = __$AggregatedNodeStatusCopyWithImpl; +@override @useResult +$Res call({ + RemoteNode node, List services, String? error +}); + + +@override $RemoteNodeCopyWith<$Res> get node; + +} +/// @nodoc +class __$AggregatedNodeStatusCopyWithImpl<$Res> + implements _$AggregatedNodeStatusCopyWith<$Res> { + __$AggregatedNodeStatusCopyWithImpl(this._self, this._then); + + final _AggregatedNodeStatus _self; + final $Res Function(_AggregatedNodeStatus) _then; + +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? node = null,Object? services = null,Object? error = freezed,}) { + return _then(_AggregatedNodeStatus( +node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable +as RemoteNode,services: null == services ? _self._services : services // ignore: cast_nullable_to_non_nullable +as List,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of AggregatedNodeStatus +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RemoteNodeCopyWith<$Res> get node { + + return $RemoteNodeCopyWith<$Res>(_self.node, (value) { + return _then(_self.copyWith(node: value)); + }); +} +} + +// dart format on diff --git a/lib/core/models/aggregated_node_status.g.dart b/lib/core/models/aggregated_node_status.g.dart new file mode 100644 index 0000000..e6ca9c1 --- /dev/null +++ b/lib/core/models/aggregated_node_status.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'aggregated_node_status.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AggregatedNodeStatus _$AggregatedNodeStatusFromJson( + Map json, +) => _AggregatedNodeStatus( + node: RemoteNode.fromJson(json['node'] as Map), + services: + (json['services'] as List?) + ?.map((e) => Service.fromJson(e as Map)) + .toList() ?? + const [], + error: json['error'] as String?, +); + +Map _$AggregatedNodeStatusToJson( + _AggregatedNodeStatus instance, +) => { + 'node': instance.node.toJson(), + 'services': instance.services.map((e) => e.toJson()).toList(), + 'error': instance.error, +}; diff --git a/lib/core/models/backup_config.dart b/lib/core/models/backup_config.dart new file mode 100644 index 0000000..507be3c --- /dev/null +++ b/lib/core/models/backup_config.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'backup_execution.dart'; +import 'backup_target.dart'; + +part 'backup_config.freezed.dart'; +part 'backup_config.g.dart'; + +/// Mirrors `models.BackupConfig`. Used both at node level (`GET /node/backup`) +/// and per-service (`Service.backup`). `nextRun`/`lastRun` here are +/// aggregates (earliest/latest) across `targets` — each target now tracks +/// its own `schedule`/`lastRun`/`nextRun` too. There's no `frequency` field +/// on the Go side anymore; scheduling moved to a per-target cron `schedule`. +@freezed +sealed class BackupConfig with _$BackupConfig { + const factory BackupConfig({ + @JsonKey(name: 'source_path') String? sourcePath, + @Default([]) List targets, + @JsonKey(name: 'next_run') DateTime? nextRun, + @JsonKey(name: 'last_run') DateTime? lastRun, + @Default([]) List history, + }) = _BackupConfig; + + factory BackupConfig.fromJson(Map json) => _$BackupConfigFromJson(json); +} diff --git a/lib/core/models/backup_config.freezed.dart b/lib/core/models/backup_config.freezed.dart new file mode 100644 index 0000000..b0f4349 --- /dev/null +++ b/lib/core/models/backup_config.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'backup_config.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BackupConfig { + +@JsonKey(name: 'source_path') String? get sourcePath; List get targets;@JsonKey(name: 'next_run') DateTime? get nextRun;@JsonKey(name: 'last_run') DateTime? get lastRun; List get history; +/// Create a copy of BackupConfig +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BackupConfigCopyWith get copyWith => _$BackupConfigCopyWithImpl(this as BackupConfig, _$identity); + + /// Serializes this BackupConfig to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupConfig&&(identical(other.sourcePath, sourcePath) || other.sourcePath == sourcePath)&&const DeepCollectionEquality().equals(other.targets, targets)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&const DeepCollectionEquality().equals(other.history, history)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sourcePath,const DeepCollectionEquality().hash(targets),nextRun,lastRun,const DeepCollectionEquality().hash(history)); + +@override +String toString() { + return 'BackupConfig(sourcePath: $sourcePath, targets: $targets, nextRun: $nextRun, lastRun: $lastRun, history: $history)'; +} + + +} + +/// @nodoc +abstract mixin class $BackupConfigCopyWith<$Res> { + factory $BackupConfigCopyWith(BackupConfig value, $Res Function(BackupConfig) _then) = _$BackupConfigCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'source_path') String? sourcePath, List targets,@JsonKey(name: 'next_run') DateTime? nextRun,@JsonKey(name: 'last_run') DateTime? lastRun, List history +}); + + + + +} +/// @nodoc +class _$BackupConfigCopyWithImpl<$Res> + implements $BackupConfigCopyWith<$Res> { + _$BackupConfigCopyWithImpl(this._self, this._then); + + final BackupConfig _self; + final $Res Function(BackupConfig) _then; + +/// Create a copy of BackupConfig +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sourcePath = freezed,Object? targets = null,Object? nextRun = freezed,Object? lastRun = freezed,Object? history = null,}) { + return _then(_self.copyWith( +sourcePath: freezed == sourcePath ? _self.sourcePath : sourcePath // ignore: cast_nullable_to_non_nullable +as String?,targets: null == targets ? _self.targets : targets // ignore: cast_nullable_to_non_nullable +as List,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable +as DateTime?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable +as DateTime?,history: null == history ? _self.history : history // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BackupConfig]. +extension BackupConfigPatterns on BackupConfig { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BackupConfig value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BackupConfig() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BackupConfig value) $default,){ +final _that = this; +switch (_that) { +case _BackupConfig(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BackupConfig value)? $default,){ +final _that = this; +switch (_that) { +case _BackupConfig() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'source_path') String? sourcePath, List targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List history)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BackupConfig() when $default != null: +return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'source_path') String? sourcePath, List targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List history) $default,) {final _that = this; +switch (_that) { +case _BackupConfig(): +return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'source_path') String? sourcePath, List targets, @JsonKey(name: 'next_run') DateTime? nextRun, @JsonKey(name: 'last_run') DateTime? lastRun, List history)? $default,) {final _that = this; +switch (_that) { +case _BackupConfig() when $default != null: +return $default(_that.sourcePath,_that.targets,_that.nextRun,_that.lastRun,_that.history);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BackupConfig implements BackupConfig { + const _BackupConfig({@JsonKey(name: 'source_path') this.sourcePath, final List targets = const [], @JsonKey(name: 'next_run') this.nextRun, @JsonKey(name: 'last_run') this.lastRun, final List history = const []}): _targets = targets,_history = history; + factory _BackupConfig.fromJson(Map json) => _$BackupConfigFromJson(json); + +@override@JsonKey(name: 'source_path') final String? sourcePath; + final List _targets; +@override@JsonKey() List get targets { + if (_targets is EqualUnmodifiableListView) return _targets; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_targets); +} + +@override@JsonKey(name: 'next_run') final DateTime? nextRun; +@override@JsonKey(name: 'last_run') final DateTime? lastRun; + final List _history; +@override@JsonKey() List get history { + if (_history is EqualUnmodifiableListView) return _history; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_history); +} + + +/// Create a copy of BackupConfig +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BackupConfigCopyWith<_BackupConfig> get copyWith => __$BackupConfigCopyWithImpl<_BackupConfig>(this, _$identity); + +@override +Map toJson() { + return _$BackupConfigToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupConfig&&(identical(other.sourcePath, sourcePath) || other.sourcePath == sourcePath)&&const DeepCollectionEquality().equals(other._targets, _targets)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&const DeepCollectionEquality().equals(other._history, _history)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sourcePath,const DeepCollectionEquality().hash(_targets),nextRun,lastRun,const DeepCollectionEquality().hash(_history)); + +@override +String toString() { + return 'BackupConfig(sourcePath: $sourcePath, targets: $targets, nextRun: $nextRun, lastRun: $lastRun, history: $history)'; +} + + +} + +/// @nodoc +abstract mixin class _$BackupConfigCopyWith<$Res> implements $BackupConfigCopyWith<$Res> { + factory _$BackupConfigCopyWith(_BackupConfig value, $Res Function(_BackupConfig) _then) = __$BackupConfigCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'source_path') String? sourcePath, List targets,@JsonKey(name: 'next_run') DateTime? nextRun,@JsonKey(name: 'last_run') DateTime? lastRun, List history +}); + + + + +} +/// @nodoc +class __$BackupConfigCopyWithImpl<$Res> + implements _$BackupConfigCopyWith<$Res> { + __$BackupConfigCopyWithImpl(this._self, this._then); + + final _BackupConfig _self; + final $Res Function(_BackupConfig) _then; + +/// Create a copy of BackupConfig +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sourcePath = freezed,Object? targets = null,Object? nextRun = freezed,Object? lastRun = freezed,Object? history = null,}) { + return _then(_BackupConfig( +sourcePath: freezed == sourcePath ? _self.sourcePath : sourcePath // ignore: cast_nullable_to_non_nullable +as String?,targets: null == targets ? _self._targets : targets // ignore: cast_nullable_to_non_nullable +as List,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable +as DateTime?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable +as DateTime?,history: null == history ? _self._history : history // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/backup_config.g.dart b/lib/core/models/backup_config.g.dart new file mode 100644 index 0000000..15c1a68 --- /dev/null +++ b/lib/core/models/backup_config.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backup_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BackupConfig _$BackupConfigFromJson(Map json) => + _BackupConfig( + sourcePath: json['source_path'] as String?, + targets: + (json['targets'] as List?) + ?.map((e) => BackupTarget.fromJson(e as Map)) + .toList() ?? + const [], + nextRun: json['next_run'] == null + ? null + : DateTime.parse(json['next_run'] as String), + lastRun: json['last_run'] == null + ? null + : DateTime.parse(json['last_run'] as String), + history: + (json['history'] as List?) + ?.map((e) => BackupExecution.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$BackupConfigToJson(_BackupConfig instance) => + { + 'source_path': instance.sourcePath, + 'targets': instance.targets.map((e) => e.toJson()).toList(), + 'next_run': instance.nextRun?.toIso8601String(), + 'last_run': instance.lastRun?.toIso8601String(), + 'history': instance.history.map((e) => e.toJson()).toList(), + }; diff --git a/lib/core/models/backup_execution.dart b/lib/core/models/backup_execution.dart new file mode 100644 index 0000000..ef50cec --- /dev/null +++ b/lib/core/models/backup_execution.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'backup_execution.freezed.dart'; +part 'backup_execution.g.dart'; + +/// Mirrors `models.BackupExecution`. +@freezed +sealed class BackupExecution with _$BackupExecution { + const factory BackupExecution({ + required String id, + required DateTime timestamp, + required bool success, + @JsonKey(name: 'duration_seconds') required int durationSeconds, + @Default('') String message, + @JsonKey(name: 'target_id') String? targetId, + }) = _BackupExecution; + + factory BackupExecution.fromJson(Map json) => _$BackupExecutionFromJson(json); +} diff --git a/lib/core/models/backup_execution.freezed.dart b/lib/core/models/backup_execution.freezed.dart new file mode 100644 index 0000000..3440c6c --- /dev/null +++ b/lib/core/models/backup_execution.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'backup_execution.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BackupExecution { + + String get id; DateTime get timestamp; bool get success;@JsonKey(name: 'duration_seconds') int get durationSeconds; String get message;@JsonKey(name: 'target_id') String? get targetId; +/// Create a copy of BackupExecution +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BackupExecutionCopyWith get copyWith => _$BackupExecutionCopyWithImpl(this as BackupExecution, _$identity); + + /// Serializes this BackupExecution to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupExecution&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(identical(other.message, message) || other.message == message)&&(identical(other.targetId, targetId) || other.targetId == targetId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,success,durationSeconds,message,targetId); + +@override +String toString() { + return 'BackupExecution(id: $id, timestamp: $timestamp, success: $success, durationSeconds: $durationSeconds, message: $message, targetId: $targetId)'; +} + + +} + +/// @nodoc +abstract mixin class $BackupExecutionCopyWith<$Res> { + factory $BackupExecutionCopyWith(BackupExecution value, $Res Function(BackupExecution) _then) = _$BackupExecutionCopyWithImpl; +@useResult +$Res call({ + String id, DateTime timestamp, bool success,@JsonKey(name: 'duration_seconds') int durationSeconds, String message,@JsonKey(name: 'target_id') String? targetId +}); + + + + +} +/// @nodoc +class _$BackupExecutionCopyWithImpl<$Res> + implements $BackupExecutionCopyWith<$Res> { + _$BackupExecutionCopyWithImpl(this._self, this._then); + + final BackupExecution _self; + final $Res Function(BackupExecution) _then; + +/// Create a copy of BackupExecution +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? durationSeconds = null,Object? message = null,Object? targetId = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,durationSeconds: null == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable +as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,targetId: freezed == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BackupExecution]. +extension BackupExecutionPatterns on BackupExecution { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BackupExecution value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BackupExecution() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BackupExecution value) $default,){ +final _that = this; +switch (_that) { +case _BackupExecution(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BackupExecution value)? $default,){ +final _that = this; +switch (_that) { +case _BackupExecution() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BackupExecution() when $default != null: +return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId) $default,) {final _that = this; +switch (_that) { +case _BackupExecution(): +return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, DateTime timestamp, bool success, @JsonKey(name: 'duration_seconds') int durationSeconds, String message, @JsonKey(name: 'target_id') String? targetId)? $default,) {final _that = this; +switch (_that) { +case _BackupExecution() when $default != null: +return $default(_that.id,_that.timestamp,_that.success,_that.durationSeconds,_that.message,_that.targetId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BackupExecution implements BackupExecution { + const _BackupExecution({required this.id, required this.timestamp, required this.success, @JsonKey(name: 'duration_seconds') required this.durationSeconds, this.message = '', @JsonKey(name: 'target_id') this.targetId}); + factory _BackupExecution.fromJson(Map json) => _$BackupExecutionFromJson(json); + +@override final String id; +@override final DateTime timestamp; +@override final bool success; +@override@JsonKey(name: 'duration_seconds') final int durationSeconds; +@override@JsonKey() final String message; +@override@JsonKey(name: 'target_id') final String? targetId; + +/// Create a copy of BackupExecution +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BackupExecutionCopyWith<_BackupExecution> get copyWith => __$BackupExecutionCopyWithImpl<_BackupExecution>(this, _$identity); + +@override +Map toJson() { + return _$BackupExecutionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupExecution&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&(identical(other.durationSeconds, durationSeconds) || other.durationSeconds == durationSeconds)&&(identical(other.message, message) || other.message == message)&&(identical(other.targetId, targetId) || other.targetId == targetId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,success,durationSeconds,message,targetId); + +@override +String toString() { + return 'BackupExecution(id: $id, timestamp: $timestamp, success: $success, durationSeconds: $durationSeconds, message: $message, targetId: $targetId)'; +} + + +} + +/// @nodoc +abstract mixin class _$BackupExecutionCopyWith<$Res> implements $BackupExecutionCopyWith<$Res> { + factory _$BackupExecutionCopyWith(_BackupExecution value, $Res Function(_BackupExecution) _then) = __$BackupExecutionCopyWithImpl; +@override @useResult +$Res call({ + String id, DateTime timestamp, bool success,@JsonKey(name: 'duration_seconds') int durationSeconds, String message,@JsonKey(name: 'target_id') String? targetId +}); + + + + +} +/// @nodoc +class __$BackupExecutionCopyWithImpl<$Res> + implements _$BackupExecutionCopyWith<$Res> { + __$BackupExecutionCopyWithImpl(this._self, this._then); + + final _BackupExecution _self; + final $Res Function(_BackupExecution) _then; + +/// Create a copy of BackupExecution +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? durationSeconds = null,Object? message = null,Object? targetId = freezed,}) { + return _then(_BackupExecution( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,durationSeconds: null == durationSeconds ? _self.durationSeconds : durationSeconds // ignore: cast_nullable_to_non_nullable +as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,targetId: freezed == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/backup_execution.g.dart b/lib/core/models/backup_execution.g.dart new file mode 100644 index 0000000..e99cb13 --- /dev/null +++ b/lib/core/models/backup_execution.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backup_execution.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BackupExecution _$BackupExecutionFromJson(Map json) => + _BackupExecution( + id: json['id'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + success: json['success'] as bool, + durationSeconds: (json['duration_seconds'] as num).toInt(), + message: json['message'] as String? ?? '', + targetId: json['target_id'] as String?, + ); + +Map _$BackupExecutionToJson(_BackupExecution instance) => + { + 'id': instance.id, + 'timestamp': instance.timestamp.toIso8601String(), + 'success': instance.success, + 'duration_seconds': instance.durationSeconds, + 'message': instance.message, + 'target_id': instance.targetId, + }; diff --git a/lib/core/models/backup_method.dart b/lib/core/models/backup_method.dart new file mode 100644 index 0000000..1df051a --- /dev/null +++ b/lib/core/models/backup_method.dart @@ -0,0 +1,11 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// Mirrors `models.BackupMethod` in `nodemaster/models/types.go`. +enum BackupMethod { + @JsonValue('rsync') + rsync, + @JsonValue('restic') + restic, + @JsonValue('external') + external, +} diff --git a/lib/core/models/backup_target.dart b/lib/core/models/backup_target.dart new file mode 100644 index 0000000..d5c850c --- /dev/null +++ b/lib/core/models/backup_target.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'backup_method.dart'; + +part 'backup_target.freezed.dart'; +part 'backup_target.g.dart'; + +/// Mirrors `models.BackupTarget`. `schedule` is a standard 5-field cron +/// expression; empty means manual-only (no automatic scheduling). +@freezed +sealed class BackupTarget with _$BackupTarget { + const factory BackupTarget({ + @Default('') String id, + required String name, + required BackupMethod method, + required String remote, + @Default({}) Map params, + String? schedule, + @JsonKey(name: 'last_run') DateTime? lastRun, + @JsonKey(name: 'next_run') DateTime? nextRun, + }) = _BackupTarget; + + factory BackupTarget.fromJson(Map json) => _$BackupTargetFromJson(json); +} diff --git a/lib/core/models/backup_target.freezed.dart b/lib/core/models/backup_target.freezed.dart new file mode 100644 index 0000000..6054d84 --- /dev/null +++ b/lib/core/models/backup_target.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'backup_target.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BackupTarget { + + String get id; String get name; BackupMethod get method; String get remote; Map get params; String? get schedule;@JsonKey(name: 'last_run') DateTime? get lastRun;@JsonKey(name: 'next_run') DateTime? get nextRun; +/// Create a copy of BackupTarget +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BackupTargetCopyWith get copyWith => _$BackupTargetCopyWithImpl(this as BackupTarget, _$identity); + + /// Serializes this BackupTarget to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BackupTarget&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.method, method) || other.method == method)&&(identical(other.remote, remote) || other.remote == remote)&&const DeepCollectionEquality().equals(other.params, params)&&(identical(other.schedule, schedule) || other.schedule == schedule)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,method,remote,const DeepCollectionEquality().hash(params),schedule,lastRun,nextRun); + +@override +String toString() { + return 'BackupTarget(id: $id, name: $name, method: $method, remote: $remote, params: $params, schedule: $schedule, lastRun: $lastRun, nextRun: $nextRun)'; +} + + +} + +/// @nodoc +abstract mixin class $BackupTargetCopyWith<$Res> { + factory $BackupTargetCopyWith(BackupTarget value, $Res Function(BackupTarget) _then) = _$BackupTargetCopyWithImpl; +@useResult +$Res call({ + String id, String name, BackupMethod method, String remote, Map params, String? schedule,@JsonKey(name: 'last_run') DateTime? lastRun,@JsonKey(name: 'next_run') DateTime? nextRun +}); + + + + +} +/// @nodoc +class _$BackupTargetCopyWithImpl<$Res> + implements $BackupTargetCopyWith<$Res> { + _$BackupTargetCopyWithImpl(this._self, this._then); + + final BackupTarget _self; + final $Res Function(BackupTarget) _then; + +/// Create a copy of BackupTarget +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? method = null,Object? remote = null,Object? params = null,Object? schedule = freezed,Object? lastRun = freezed,Object? nextRun = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable +as BackupMethod,remote: null == remote ? _self.remote : remote // ignore: cast_nullable_to_non_nullable +as String,params: null == params ? _self.params : params // ignore: cast_nullable_to_non_nullable +as Map,schedule: freezed == schedule ? _self.schedule : schedule // ignore: cast_nullable_to_non_nullable +as String?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable +as DateTime?,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BackupTarget]. +extension BackupTargetPatterns on BackupTarget { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BackupTarget value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BackupTarget() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BackupTarget value) $default,){ +final _that = this; +switch (_that) { +case _BackupTarget(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BackupTarget value)? $default,){ +final _that = this; +switch (_that) { +case _BackupTarget() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, BackupMethod method, String remote, Map params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BackupTarget() when $default != null: +return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, BackupMethod method, String remote, Map params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun) $default,) {final _that = this; +switch (_that) { +case _BackupTarget(): +return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, BackupMethod method, String remote, Map params, String? schedule, @JsonKey(name: 'last_run') DateTime? lastRun, @JsonKey(name: 'next_run') DateTime? nextRun)? $default,) {final _that = this; +switch (_that) { +case _BackupTarget() when $default != null: +return $default(_that.id,_that.name,_that.method,_that.remote,_that.params,_that.schedule,_that.lastRun,_that.nextRun);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BackupTarget implements BackupTarget { + const _BackupTarget({this.id = '', required this.name, required this.method, required this.remote, final Map params = const {}, this.schedule, @JsonKey(name: 'last_run') this.lastRun, @JsonKey(name: 'next_run') this.nextRun}): _params = params; + factory _BackupTarget.fromJson(Map json) => _$BackupTargetFromJson(json); + +@override@JsonKey() final String id; +@override final String name; +@override final BackupMethod method; +@override final String remote; + final Map _params; +@override@JsonKey() Map get params { + if (_params is EqualUnmodifiableMapView) return _params; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_params); +} + +@override final String? schedule; +@override@JsonKey(name: 'last_run') final DateTime? lastRun; +@override@JsonKey(name: 'next_run') final DateTime? nextRun; + +/// Create a copy of BackupTarget +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BackupTargetCopyWith<_BackupTarget> get copyWith => __$BackupTargetCopyWithImpl<_BackupTarget>(this, _$identity); + +@override +Map toJson() { + return _$BackupTargetToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BackupTarget&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.method, method) || other.method == method)&&(identical(other.remote, remote) || other.remote == remote)&&const DeepCollectionEquality().equals(other._params, _params)&&(identical(other.schedule, schedule) || other.schedule == schedule)&&(identical(other.lastRun, lastRun) || other.lastRun == lastRun)&&(identical(other.nextRun, nextRun) || other.nextRun == nextRun)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,method,remote,const DeepCollectionEquality().hash(_params),schedule,lastRun,nextRun); + +@override +String toString() { + return 'BackupTarget(id: $id, name: $name, method: $method, remote: $remote, params: $params, schedule: $schedule, lastRun: $lastRun, nextRun: $nextRun)'; +} + + +} + +/// @nodoc +abstract mixin class _$BackupTargetCopyWith<$Res> implements $BackupTargetCopyWith<$Res> { + factory _$BackupTargetCopyWith(_BackupTarget value, $Res Function(_BackupTarget) _then) = __$BackupTargetCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, BackupMethod method, String remote, Map params, String? schedule,@JsonKey(name: 'last_run') DateTime? lastRun,@JsonKey(name: 'next_run') DateTime? nextRun +}); + + + + +} +/// @nodoc +class __$BackupTargetCopyWithImpl<$Res> + implements _$BackupTargetCopyWith<$Res> { + __$BackupTargetCopyWithImpl(this._self, this._then); + + final _BackupTarget _self; + final $Res Function(_BackupTarget) _then; + +/// Create a copy of BackupTarget +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? method = null,Object? remote = null,Object? params = null,Object? schedule = freezed,Object? lastRun = freezed,Object? nextRun = freezed,}) { + return _then(_BackupTarget( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,method: null == method ? _self.method : method // ignore: cast_nullable_to_non_nullable +as BackupMethod,remote: null == remote ? _self.remote : remote // ignore: cast_nullable_to_non_nullable +as String,params: null == params ? _self._params : params // ignore: cast_nullable_to_non_nullable +as Map,schedule: freezed == schedule ? _self.schedule : schedule // ignore: cast_nullable_to_non_nullable +as String?,lastRun: freezed == lastRun ? _self.lastRun : lastRun // ignore: cast_nullable_to_non_nullable +as DateTime?,nextRun: freezed == nextRun ? _self.nextRun : nextRun // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/backup_target.g.dart b/lib/core/models/backup_target.g.dart new file mode 100644 index 0000000..37a4228 --- /dev/null +++ b/lib/core/models/backup_target.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'backup_target.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BackupTarget _$BackupTargetFromJson(Map json) => + _BackupTarget( + id: json['id'] as String? ?? '', + name: json['name'] as String, + method: $enumDecode(_$BackupMethodEnumMap, json['method']), + remote: json['remote'] as String, + params: + (json['params'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ) ?? + const {}, + schedule: json['schedule'] as String?, + lastRun: json['last_run'] == null + ? null + : DateTime.parse(json['last_run'] as String), + nextRun: json['next_run'] == null + ? null + : DateTime.parse(json['next_run'] as String), + ); + +Map _$BackupTargetToJson(_BackupTarget instance) => + { + 'id': instance.id, + 'name': instance.name, + 'method': _$BackupMethodEnumMap[instance.method]!, + 'remote': instance.remote, + 'params': instance.params, + 'schedule': instance.schedule, + 'last_run': instance.lastRun?.toIso8601String(), + 'next_run': instance.nextRun?.toIso8601String(), + }; + +const _$BackupMethodEnumMap = { + BackupMethod.rsync: 'rsync', + BackupMethod.restic: 'restic', + BackupMethod.external: 'external', +}; diff --git a/lib/core/models/models.dart b/lib/core/models/models.dart new file mode 100644 index 0000000..3b4c5e9 --- /dev/null +++ b/lib/core/models/models.dart @@ -0,0 +1,12 @@ +export 'aggregated_node_status.dart'; +export 'backup_config.dart'; +export 'backup_execution.dart'; +export 'backup_method.dart'; +export 'backup_target.dart'; +export 'node_info.dart'; +export 'remote_node.dart'; +export 'run_state.dart'; +export 'scan_result.dart'; +export 'service.dart'; +export 'service_scan_info.dart'; +export 'update_record.dart'; diff --git a/lib/core/models/node_info.dart b/lib/core/models/node_info.dart new file mode 100644 index 0000000..e40b733 --- /dev/null +++ b/lib/core/models/node_info.dart @@ -0,0 +1,30 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'backup_config.dart'; +import 'update_record.dart'; + +part 'node_info.freezed.dart'; +part 'node_info.g.dart'; + +/// Mirrors `models.NodeInfo` — the local/current host's identity + backup +/// config + update log, as served by `GET /node/`. +/// +/// `resticPassword` is write-only: the API never echoes the real password +/// back (`GET /node/` sends `restic_password_set` instead, see +/// `controllers/node.go`), so this stays `null` unless the user is actively +/// setting/changing it in the identity form, and is omitted from the PUT +/// body entirely when `null` so an untouched form never clobbers the +/// existing password (mirrors the Go side's "empty string = don't change"). +@freezed +sealed class NodeInfo with _$NodeInfo { + const factory NodeInfo({ + required String hostname, + String? description, + @JsonKey(name: 'update_history') @Default([]) List updateHistory, + required BackupConfig backup, + @JsonKey(name: 'restic_password_set') @Default(false) bool resticPasswordSet, + @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword, + }) = _NodeInfo; + + factory NodeInfo.fromJson(Map json) => _$NodeInfoFromJson(json); +} diff --git a/lib/core/models/node_info.freezed.dart b/lib/core/models/node_info.freezed.dart new file mode 100644 index 0000000..f0a28d0 --- /dev/null +++ b/lib/core/models/node_info.freezed.dart @@ -0,0 +1,310 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'node_info.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NodeInfo { + + String get hostname; String? get description;@JsonKey(name: 'update_history') List get updateHistory; BackupConfig get backup;@JsonKey(name: 'restic_password_set') bool get resticPasswordSet;@JsonKey(name: 'restic_password', includeIfNull: false) String? get resticPassword; +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NodeInfoCopyWith get copyWith => _$NodeInfoCopyWithImpl(this as NodeInfo, _$identity); + + /// Serializes this NodeInfo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NodeInfo&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.updateHistory, updateHistory)&&(identical(other.backup, backup) || other.backup == backup)&&(identical(other.resticPasswordSet, resticPasswordSet) || other.resticPasswordSet == resticPasswordSet)&&(identical(other.resticPassword, resticPassword) || other.resticPassword == resticPassword)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,hostname,description,const DeepCollectionEquality().hash(updateHistory),backup,resticPasswordSet,resticPassword); + +@override +String toString() { + return 'NodeInfo(hostname: $hostname, description: $description, updateHistory: $updateHistory, backup: $backup, resticPasswordSet: $resticPasswordSet, resticPassword: $resticPassword)'; +} + + +} + +/// @nodoc +abstract mixin class $NodeInfoCopyWith<$Res> { + factory $NodeInfoCopyWith(NodeInfo value, $Res Function(NodeInfo) _then) = _$NodeInfoCopyWithImpl; +@useResult +$Res call({ + String hostname, String? description,@JsonKey(name: 'update_history') List updateHistory, BackupConfig backup,@JsonKey(name: 'restic_password_set') bool resticPasswordSet,@JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword +}); + + +$BackupConfigCopyWith<$Res> get backup; + +} +/// @nodoc +class _$NodeInfoCopyWithImpl<$Res> + implements $NodeInfoCopyWith<$Res> { + _$NodeInfoCopyWithImpl(this._self, this._then); + + final NodeInfo _self; + final $Res Function(NodeInfo) _then; + +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? hostname = null,Object? description = freezed,Object? updateHistory = null,Object? backup = null,Object? resticPasswordSet = null,Object? resticPassword = freezed,}) { + return _then(_self.copyWith( +hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,updateHistory: null == updateHistory ? _self.updateHistory : updateHistory // ignore: cast_nullable_to_non_nullable +as List,backup: null == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable +as BackupConfig,resticPasswordSet: null == resticPasswordSet ? _self.resticPasswordSet : resticPasswordSet // ignore: cast_nullable_to_non_nullable +as bool,resticPassword: freezed == resticPassword ? _self.resticPassword : resticPassword // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BackupConfigCopyWith<$Res> get backup { + + return $BackupConfigCopyWith<$Res>(_self.backup, (value) { + return _then(_self.copyWith(backup: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [NodeInfo]. +extension NodeInfoPatterns on NodeInfo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _NodeInfo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NodeInfo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _NodeInfo value) $default,){ +final _that = this; +switch (_that) { +case _NodeInfo(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _NodeInfo value)? $default,){ +final _that = this; +switch (_that) { +case _NodeInfo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String hostname, String? description, @JsonKey(name: 'update_history') List updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NodeInfo() when $default != null: +return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String hostname, String? description, @JsonKey(name: 'update_history') List updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword) $default,) {final _that = this; +switch (_that) { +case _NodeInfo(): +return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String hostname, String? description, @JsonKey(name: 'update_history') List updateHistory, BackupConfig backup, @JsonKey(name: 'restic_password_set') bool resticPasswordSet, @JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword)? $default,) {final _that = this; +switch (_that) { +case _NodeInfo() when $default != null: +return $default(_that.hostname,_that.description,_that.updateHistory,_that.backup,_that.resticPasswordSet,_that.resticPassword);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _NodeInfo implements NodeInfo { + const _NodeInfo({required this.hostname, this.description, @JsonKey(name: 'update_history') final List updateHistory = const [], required this.backup, @JsonKey(name: 'restic_password_set') this.resticPasswordSet = false, @JsonKey(name: 'restic_password', includeIfNull: false) this.resticPassword}): _updateHistory = updateHistory; + factory _NodeInfo.fromJson(Map json) => _$NodeInfoFromJson(json); + +@override final String hostname; +@override final String? description; + final List _updateHistory; +@override@JsonKey(name: 'update_history') List get updateHistory { + if (_updateHistory is EqualUnmodifiableListView) return _updateHistory; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_updateHistory); +} + +@override final BackupConfig backup; +@override@JsonKey(name: 'restic_password_set') final bool resticPasswordSet; +@override@JsonKey(name: 'restic_password', includeIfNull: false) final String? resticPassword; + +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NodeInfoCopyWith<_NodeInfo> get copyWith => __$NodeInfoCopyWithImpl<_NodeInfo>(this, _$identity); + +@override +Map toJson() { + return _$NodeInfoToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NodeInfo&&(identical(other.hostname, hostname) || other.hostname == hostname)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._updateHistory, _updateHistory)&&(identical(other.backup, backup) || other.backup == backup)&&(identical(other.resticPasswordSet, resticPasswordSet) || other.resticPasswordSet == resticPasswordSet)&&(identical(other.resticPassword, resticPassword) || other.resticPassword == resticPassword)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,hostname,description,const DeepCollectionEquality().hash(_updateHistory),backup,resticPasswordSet,resticPassword); + +@override +String toString() { + return 'NodeInfo(hostname: $hostname, description: $description, updateHistory: $updateHistory, backup: $backup, resticPasswordSet: $resticPasswordSet, resticPassword: $resticPassword)'; +} + + +} + +/// @nodoc +abstract mixin class _$NodeInfoCopyWith<$Res> implements $NodeInfoCopyWith<$Res> { + factory _$NodeInfoCopyWith(_NodeInfo value, $Res Function(_NodeInfo) _then) = __$NodeInfoCopyWithImpl; +@override @useResult +$Res call({ + String hostname, String? description,@JsonKey(name: 'update_history') List updateHistory, BackupConfig backup,@JsonKey(name: 'restic_password_set') bool resticPasswordSet,@JsonKey(name: 'restic_password', includeIfNull: false) String? resticPassword +}); + + +@override $BackupConfigCopyWith<$Res> get backup; + +} +/// @nodoc +class __$NodeInfoCopyWithImpl<$Res> + implements _$NodeInfoCopyWith<$Res> { + __$NodeInfoCopyWithImpl(this._self, this._then); + + final _NodeInfo _self; + final $Res Function(_NodeInfo) _then; + +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? hostname = null,Object? description = freezed,Object? updateHistory = null,Object? backup = null,Object? resticPasswordSet = null,Object? resticPassword = freezed,}) { + return _then(_NodeInfo( +hostname: null == hostname ? _self.hostname : hostname // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,updateHistory: null == updateHistory ? _self._updateHistory : updateHistory // ignore: cast_nullable_to_non_nullable +as List,backup: null == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable +as BackupConfig,resticPasswordSet: null == resticPasswordSet ? _self.resticPasswordSet : resticPasswordSet // ignore: cast_nullable_to_non_nullable +as bool,resticPassword: freezed == resticPassword ? _self.resticPassword : resticPassword // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of NodeInfo +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BackupConfigCopyWith<$Res> get backup { + + return $BackupConfigCopyWith<$Res>(_self.backup, (value) { + return _then(_self.copyWith(backup: value)); + }); +} +} + +// dart format on diff --git a/lib/core/models/node_info.g.dart b/lib/core/models/node_info.g.dart new file mode 100644 index 0000000..7b7779e --- /dev/null +++ b/lib/core/models/node_info.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'node_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NodeInfo _$NodeInfoFromJson(Map json) => _NodeInfo( + hostname: json['hostname'] as String, + description: json['description'] as String?, + updateHistory: + (json['update_history'] as List?) + ?.map((e) => UpdateRecord.fromJson(e as Map)) + .toList() ?? + const [], + backup: BackupConfig.fromJson(json['backup'] as Map), + resticPasswordSet: json['restic_password_set'] as bool? ?? false, + resticPassword: json['restic_password'] as String?, +); + +Map _$NodeInfoToJson(_NodeInfo instance) => { + 'hostname': instance.hostname, + 'description': instance.description, + 'update_history': instance.updateHistory.map((e) => e.toJson()).toList(), + 'backup': instance.backup.toJson(), + 'restic_password_set': instance.resticPasswordSet, + 'restic_password': ?instance.resticPassword, +}; diff --git a/lib/core/models/remote_node.dart b/lib/core/models/remote_node.dart new file mode 100644 index 0000000..29585c7 --- /dev/null +++ b/lib/core/models/remote_node.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'remote_node.freezed.dart'; +part 'remote_node.g.dart'; + +/// Mirrors `models.RemoteNode` — an entry in this node's registry of +/// sibling NodeMaster hosts (`/nodes/*`). +@freezed +sealed class RemoteNode with _$RemoteNode { + const factory RemoteNode({ + @Default('') String id, + required String name, + required String url, + @Default('') String status, + String? description, + @Default([]) List tags, + @JsonKey(name: 'last_seen') DateTime? lastSeen, + }) = _RemoteNode; + + factory RemoteNode.fromJson(Map json) => _$RemoteNodeFromJson(json); +} diff --git a/lib/core/models/remote_node.freezed.dart b/lib/core/models/remote_node.freezed.dart new file mode 100644 index 0000000..4542af6 --- /dev/null +++ b/lib/core/models/remote_node.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'remote_node.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RemoteNode { + + String get id; String get name; String get url; String get status; String? get description; List get tags;@JsonKey(name: 'last_seen') DateTime? get lastSeen; +/// Create a copy of RemoteNode +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RemoteNodeCopyWith get copyWith => _$RemoteNodeCopyWithImpl(this as RemoteNode, _$identity); + + /// Serializes this RemoteNode to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RemoteNode&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)&&(identical(other.status, status) || other.status == status)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.lastSeen, lastSeen) || other.lastSeen == lastSeen)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,url,status,description,const DeepCollectionEquality().hash(tags),lastSeen); + +@override +String toString() { + return 'RemoteNode(id: $id, name: $name, url: $url, status: $status, description: $description, tags: $tags, lastSeen: $lastSeen)'; +} + + +} + +/// @nodoc +abstract mixin class $RemoteNodeCopyWith<$Res> { + factory $RemoteNodeCopyWith(RemoteNode value, $Res Function(RemoteNode) _then) = _$RemoteNodeCopyWithImpl; +@useResult +$Res call({ + String id, String name, String url, String status, String? description, List tags,@JsonKey(name: 'last_seen') DateTime? lastSeen +}); + + + + +} +/// @nodoc +class _$RemoteNodeCopyWithImpl<$Res> + implements $RemoteNodeCopyWith<$Res> { + _$RemoteNodeCopyWithImpl(this._self, this._then); + + final RemoteNode _self; + final $Res Function(RemoteNode) _then; + +/// Create a copy of RemoteNode +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? url = null,Object? status = null,Object? description = freezed,Object? tags = null,Object? lastSeen = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,lastSeen: freezed == lastSeen ? _self.lastSeen : lastSeen // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RemoteNode]. +extension RemoteNodePatterns on RemoteNode { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RemoteNode value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RemoteNode() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RemoteNode value) $default,){ +final _that = this; +switch (_that) { +case _RemoteNode(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RemoteNode value)? $default,){ +final _that = this; +switch (_that) { +case _RemoteNode() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String url, String status, String? description, List tags, @JsonKey(name: 'last_seen') DateTime? lastSeen)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RemoteNode() when $default != null: +return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String url, String status, String? description, List tags, @JsonKey(name: 'last_seen') DateTime? lastSeen) $default,) {final _that = this; +switch (_that) { +case _RemoteNode(): +return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String url, String status, String? description, List tags, @JsonKey(name: 'last_seen') DateTime? lastSeen)? $default,) {final _that = this; +switch (_that) { +case _RemoteNode() when $default != null: +return $default(_that.id,_that.name,_that.url,_that.status,_that.description,_that.tags,_that.lastSeen);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _RemoteNode implements RemoteNode { + const _RemoteNode({this.id = '', required this.name, required this.url, this.status = '', this.description, final List tags = const [], @JsonKey(name: 'last_seen') this.lastSeen}): _tags = tags; + factory _RemoteNode.fromJson(Map json) => _$RemoteNodeFromJson(json); + +@override@JsonKey() final String id; +@override final String name; +@override final String url; +@override@JsonKey() final String status; +@override final String? description; + final List _tags; +@override@JsonKey() List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + +@override@JsonKey(name: 'last_seen') final DateTime? lastSeen; + +/// Create a copy of RemoteNode +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RemoteNodeCopyWith<_RemoteNode> get copyWith => __$RemoteNodeCopyWithImpl<_RemoteNode>(this, _$identity); + +@override +Map toJson() { + return _$RemoteNodeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RemoteNode&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.url, url) || other.url == url)&&(identical(other.status, status) || other.status == status)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.lastSeen, lastSeen) || other.lastSeen == lastSeen)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,url,status,description,const DeepCollectionEquality().hash(_tags),lastSeen); + +@override +String toString() { + return 'RemoteNode(id: $id, name: $name, url: $url, status: $status, description: $description, tags: $tags, lastSeen: $lastSeen)'; +} + + +} + +/// @nodoc +abstract mixin class _$RemoteNodeCopyWith<$Res> implements $RemoteNodeCopyWith<$Res> { + factory _$RemoteNodeCopyWith(_RemoteNode value, $Res Function(_RemoteNode) _then) = __$RemoteNodeCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String url, String status, String? description, List tags,@JsonKey(name: 'last_seen') DateTime? lastSeen +}); + + + + +} +/// @nodoc +class __$RemoteNodeCopyWithImpl<$Res> + implements _$RemoteNodeCopyWith<$Res> { + __$RemoteNodeCopyWithImpl(this._self, this._then); + + final _RemoteNode _self; + final $Res Function(_RemoteNode) _then; + +/// Create a copy of RemoteNode +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? url = null,Object? status = null,Object? description = freezed,Object? tags = null,Object? lastSeen = freezed,}) { + return _then(_RemoteNode( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,lastSeen: freezed == lastSeen ? _self.lastSeen : lastSeen // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/remote_node.g.dart b/lib/core/models/remote_node.g.dart new file mode 100644 index 0000000..a8f81b3 --- /dev/null +++ b/lib/core/models/remote_node.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remote_node.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RemoteNode _$RemoteNodeFromJson(Map json) => _RemoteNode( + id: json['id'] as String? ?? '', + name: json['name'] as String, + url: json['url'] as String, + status: json['status'] as String? ?? '', + description: json['description'] as String?, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + lastSeen: json['last_seen'] == null + ? null + : DateTime.parse(json['last_seen'] as String), +); + +Map _$RemoteNodeToJson(_RemoteNode instance) => + { + 'id': instance.id, + 'name': instance.name, + 'url': instance.url, + 'status': instance.status, + 'description': instance.description, + 'tags': instance.tags, + 'last_seen': instance.lastSeen?.toIso8601String(), + }; diff --git a/lib/core/models/run_state.dart b/lib/core/models/run_state.dart new file mode 100644 index 0000000..efa7adf --- /dev/null +++ b/lib/core/models/run_state.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'backup_method.dart'; + +part 'run_state.freezed.dart'; +part 'run_state.g.dart'; + +/// Mirrors `models.RunState` — the live (in-memory, non-persisted) progress +/// of a single backup target's current or most recent run. Polled from +/// `GET /.../backup/targets/:tid/progress` while a run is in flight. +@freezed +sealed class RunState with _$RunState { + const factory RunState({ + @Default(false) bool running, + @JsonKey(name: 'target_id') @Default('') String targetId, + BackupMethod? method, + @Default(0) double percent, + String? eta, + String? message, + @JsonKey(name: 'started_at') DateTime? startedAt, + }) = _RunState; + + factory RunState.fromJson(Map json) => _$RunStateFromJson(json); +} diff --git a/lib/core/models/run_state.freezed.dart b/lib/core/models/run_state.freezed.dart new file mode 100644 index 0000000..29eb71f --- /dev/null +++ b/lib/core/models/run_state.freezed.dart @@ -0,0 +1,289 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'run_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RunState { + + bool get running;@JsonKey(name: 'target_id') String get targetId; BackupMethod? get method; double get percent; String? get eta; String? get message;@JsonKey(name: 'started_at') DateTime? get startedAt; +/// Create a copy of RunState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RunStateCopyWith get copyWith => _$RunStateCopyWithImpl(this as RunState, _$identity); + + /// Serializes this RunState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RunState&&(identical(other.running, running) || other.running == running)&&(identical(other.targetId, targetId) || other.targetId == targetId)&&(identical(other.method, method) || other.method == method)&&(identical(other.percent, percent) || other.percent == percent)&&(identical(other.eta, eta) || other.eta == eta)&&(identical(other.message, message) || other.message == message)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,running,targetId,method,percent,eta,message,startedAt); + +@override +String toString() { + return 'RunState(running: $running, targetId: $targetId, method: $method, percent: $percent, eta: $eta, message: $message, startedAt: $startedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $RunStateCopyWith<$Res> { + factory $RunStateCopyWith(RunState value, $Res Function(RunState) _then) = _$RunStateCopyWithImpl; +@useResult +$Res call({ + bool running,@JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message,@JsonKey(name: 'started_at') DateTime? startedAt +}); + + + + +} +/// @nodoc +class _$RunStateCopyWithImpl<$Res> + implements $RunStateCopyWith<$Res> { + _$RunStateCopyWithImpl(this._self, this._then); + + final RunState _self; + final $Res Function(RunState) _then; + +/// Create a copy of RunState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? running = null,Object? targetId = null,Object? method = freezed,Object? percent = null,Object? eta = freezed,Object? message = freezed,Object? startedAt = freezed,}) { + return _then(_self.copyWith( +running: null == running ? _self.running : running // ignore: cast_nullable_to_non_nullable +as bool,targetId: null == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable +as String,method: freezed == method ? _self.method : method // ignore: cast_nullable_to_non_nullable +as BackupMethod?,percent: null == percent ? _self.percent : percent // ignore: cast_nullable_to_non_nullable +as double,eta: freezed == eta ? _self.eta : eta // ignore: cast_nullable_to_non_nullable +as String?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RunState]. +extension RunStatePatterns on RunState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RunState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RunState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RunState value) $default,){ +final _that = this; +switch (_that) { +case _RunState(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RunState value)? $default,){ +final _that = this; +switch (_that) { +case _RunState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RunState() when $default != null: +return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt) $default,) {final _that = this; +switch (_that) { +case _RunState(): +return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool running, @JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message, @JsonKey(name: 'started_at') DateTime? startedAt)? $default,) {final _that = this; +switch (_that) { +case _RunState() when $default != null: +return $default(_that.running,_that.targetId,_that.method,_that.percent,_that.eta,_that.message,_that.startedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _RunState implements RunState { + const _RunState({this.running = false, @JsonKey(name: 'target_id') this.targetId = '', this.method, this.percent = 0, this.eta, this.message, @JsonKey(name: 'started_at') this.startedAt}); + factory _RunState.fromJson(Map json) => _$RunStateFromJson(json); + +@override@JsonKey() final bool running; +@override@JsonKey(name: 'target_id') final String targetId; +@override final BackupMethod? method; +@override@JsonKey() final double percent; +@override final String? eta; +@override final String? message; +@override@JsonKey(name: 'started_at') final DateTime? startedAt; + +/// Create a copy of RunState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RunStateCopyWith<_RunState> get copyWith => __$RunStateCopyWithImpl<_RunState>(this, _$identity); + +@override +Map toJson() { + return _$RunStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RunState&&(identical(other.running, running) || other.running == running)&&(identical(other.targetId, targetId) || other.targetId == targetId)&&(identical(other.method, method) || other.method == method)&&(identical(other.percent, percent) || other.percent == percent)&&(identical(other.eta, eta) || other.eta == eta)&&(identical(other.message, message) || other.message == message)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,running,targetId,method,percent,eta,message,startedAt); + +@override +String toString() { + return 'RunState(running: $running, targetId: $targetId, method: $method, percent: $percent, eta: $eta, message: $message, startedAt: $startedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$RunStateCopyWith<$Res> implements $RunStateCopyWith<$Res> { + factory _$RunStateCopyWith(_RunState value, $Res Function(_RunState) _then) = __$RunStateCopyWithImpl; +@override @useResult +$Res call({ + bool running,@JsonKey(name: 'target_id') String targetId, BackupMethod? method, double percent, String? eta, String? message,@JsonKey(name: 'started_at') DateTime? startedAt +}); + + + + +} +/// @nodoc +class __$RunStateCopyWithImpl<$Res> + implements _$RunStateCopyWith<$Res> { + __$RunStateCopyWithImpl(this._self, this._then); + + final _RunState _self; + final $Res Function(_RunState) _then; + +/// Create a copy of RunState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? running = null,Object? targetId = null,Object? method = freezed,Object? percent = null,Object? eta = freezed,Object? message = freezed,Object? startedAt = freezed,}) { + return _then(_RunState( +running: null == running ? _self.running : running // ignore: cast_nullable_to_non_nullable +as bool,targetId: null == targetId ? _self.targetId : targetId // ignore: cast_nullable_to_non_nullable +as String,method: freezed == method ? _self.method : method // ignore: cast_nullable_to_non_nullable +as BackupMethod?,percent: null == percent ? _self.percent : percent // ignore: cast_nullable_to_non_nullable +as double,eta: freezed == eta ? _self.eta : eta // ignore: cast_nullable_to_non_nullable +as String?,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String?,startedAt: freezed == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/run_state.g.dart b/lib/core/models/run_state.g.dart new file mode 100644 index 0000000..85cd03d --- /dev/null +++ b/lib/core/models/run_state.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'run_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_RunState _$RunStateFromJson(Map json) => _RunState( + running: json['running'] as bool? ?? false, + targetId: json['target_id'] as String? ?? '', + method: $enumDecodeNullable(_$BackupMethodEnumMap, json['method']), + percent: (json['percent'] as num?)?.toDouble() ?? 0, + eta: json['eta'] as String?, + message: json['message'] as String?, + startedAt: json['started_at'] == null + ? null + : DateTime.parse(json['started_at'] as String), +); + +Map _$RunStateToJson(_RunState instance) => { + 'running': instance.running, + 'target_id': instance.targetId, + 'method': _$BackupMethodEnumMap[instance.method], + 'percent': instance.percent, + 'eta': instance.eta, + 'message': instance.message, + 'started_at': instance.startedAt?.toIso8601String(), +}; + +const _$BackupMethodEnumMap = { + BackupMethod.rsync: 'rsync', + BackupMethod.restic: 'restic', + BackupMethod.external: 'external', +}; diff --git a/lib/core/models/scan_result.dart b/lib/core/models/scan_result.dart new file mode 100644 index 0000000..1a9e702 --- /dev/null +++ b/lib/core/models/scan_result.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'service_scan_info.dart'; + +part 'scan_result.freezed.dart'; +part 'scan_result.g.dart'; + +/// Mirrors `models.ScanResult`, returned by `POST /node/scan`. +@freezed +sealed class ScanResult with _$ScanResult { + const factory ScanResult({ + @JsonKey(name: 'scanned_paths') @Default([]) List scannedPaths, + @Default(0) int found, + @Default([]) List created, + @Default([]) List updated, + @Default([]) List errors, + }) = _ScanResult; + + factory ScanResult.fromJson(Map json) => _$ScanResultFromJson(json); +} diff --git a/lib/core/models/scan_result.freezed.dart b/lib/core/models/scan_result.freezed.dart new file mode 100644 index 0000000..5db05d0 --- /dev/null +++ b/lib/core/models/scan_result.freezed.dart @@ -0,0 +1,307 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'scan_result.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ScanResult { + +@JsonKey(name: 'scanned_paths') List get scannedPaths; int get found; List get created; List get updated; List get errors; +/// Create a copy of ScanResult +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ScanResultCopyWith get copyWith => _$ScanResultCopyWithImpl(this as ScanResult, _$identity); + + /// Serializes this ScanResult to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ScanResult&&const DeepCollectionEquality().equals(other.scannedPaths, scannedPaths)&&(identical(other.found, found) || other.found == found)&&const DeepCollectionEquality().equals(other.created, created)&&const DeepCollectionEquality().equals(other.updated, updated)&&const DeepCollectionEquality().equals(other.errors, errors)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(scannedPaths),found,const DeepCollectionEquality().hash(created),const DeepCollectionEquality().hash(updated),const DeepCollectionEquality().hash(errors)); + +@override +String toString() { + return 'ScanResult(scannedPaths: $scannedPaths, found: $found, created: $created, updated: $updated, errors: $errors)'; +} + + +} + +/// @nodoc +abstract mixin class $ScanResultCopyWith<$Res> { + factory $ScanResultCopyWith(ScanResult value, $Res Function(ScanResult) _then) = _$ScanResultCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'scanned_paths') List scannedPaths, int found, List created, List updated, List errors +}); + + + + +} +/// @nodoc +class _$ScanResultCopyWithImpl<$Res> + implements $ScanResultCopyWith<$Res> { + _$ScanResultCopyWithImpl(this._self, this._then); + + final ScanResult _self; + final $Res Function(ScanResult) _then; + +/// Create a copy of ScanResult +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? scannedPaths = null,Object? found = null,Object? created = null,Object? updated = null,Object? errors = null,}) { + return _then(_self.copyWith( +scannedPaths: null == scannedPaths ? _self.scannedPaths : scannedPaths // ignore: cast_nullable_to_non_nullable +as List,found: null == found ? _self.found : found // ignore: cast_nullable_to_non_nullable +as int,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable +as List,updated: null == updated ? _self.updated : updated // ignore: cast_nullable_to_non_nullable +as List,errors: null == errors ? _self.errors : errors // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ScanResult]. +extension ScanResultPatterns on ScanResult { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ScanResult value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ScanResult() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ScanResult value) $default,){ +final _that = this; +switch (_that) { +case _ScanResult(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ScanResult value)? $default,){ +final _that = this; +switch (_that) { +case _ScanResult() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'scanned_paths') List scannedPaths, int found, List created, List updated, List errors)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ScanResult() when $default != null: +return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'scanned_paths') List scannedPaths, int found, List created, List updated, List errors) $default,) {final _that = this; +switch (_that) { +case _ScanResult(): +return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'scanned_paths') List scannedPaths, int found, List created, List updated, List errors)? $default,) {final _that = this; +switch (_that) { +case _ScanResult() when $default != null: +return $default(_that.scannedPaths,_that.found,_that.created,_that.updated,_that.errors);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ScanResult implements ScanResult { + const _ScanResult({@JsonKey(name: 'scanned_paths') final List scannedPaths = const [], this.found = 0, final List created = const [], final List updated = const [], final List errors = const []}): _scannedPaths = scannedPaths,_created = created,_updated = updated,_errors = errors; + factory _ScanResult.fromJson(Map json) => _$ScanResultFromJson(json); + + final List _scannedPaths; +@override@JsonKey(name: 'scanned_paths') List get scannedPaths { + if (_scannedPaths is EqualUnmodifiableListView) return _scannedPaths; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_scannedPaths); +} + +@override@JsonKey() final int found; + final List _created; +@override@JsonKey() List get created { + if (_created is EqualUnmodifiableListView) return _created; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_created); +} + + final List _updated; +@override@JsonKey() List get updated { + if (_updated is EqualUnmodifiableListView) return _updated; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_updated); +} + + final List _errors; +@override@JsonKey() List get errors { + if (_errors is EqualUnmodifiableListView) return _errors; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_errors); +} + + +/// Create a copy of ScanResult +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ScanResultCopyWith<_ScanResult> get copyWith => __$ScanResultCopyWithImpl<_ScanResult>(this, _$identity); + +@override +Map toJson() { + return _$ScanResultToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ScanResult&&const DeepCollectionEquality().equals(other._scannedPaths, _scannedPaths)&&(identical(other.found, found) || other.found == found)&&const DeepCollectionEquality().equals(other._created, _created)&&const DeepCollectionEquality().equals(other._updated, _updated)&&const DeepCollectionEquality().equals(other._errors, _errors)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_scannedPaths),found,const DeepCollectionEquality().hash(_created),const DeepCollectionEquality().hash(_updated),const DeepCollectionEquality().hash(_errors)); + +@override +String toString() { + return 'ScanResult(scannedPaths: $scannedPaths, found: $found, created: $created, updated: $updated, errors: $errors)'; +} + + +} + +/// @nodoc +abstract mixin class _$ScanResultCopyWith<$Res> implements $ScanResultCopyWith<$Res> { + factory _$ScanResultCopyWith(_ScanResult value, $Res Function(_ScanResult) _then) = __$ScanResultCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'scanned_paths') List scannedPaths, int found, List created, List updated, List errors +}); + + + + +} +/// @nodoc +class __$ScanResultCopyWithImpl<$Res> + implements _$ScanResultCopyWith<$Res> { + __$ScanResultCopyWithImpl(this._self, this._then); + + final _ScanResult _self; + final $Res Function(_ScanResult) _then; + +/// Create a copy of ScanResult +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? scannedPaths = null,Object? found = null,Object? created = null,Object? updated = null,Object? errors = null,}) { + return _then(_ScanResult( +scannedPaths: null == scannedPaths ? _self._scannedPaths : scannedPaths // ignore: cast_nullable_to_non_nullable +as List,found: null == found ? _self.found : found // ignore: cast_nullable_to_non_nullable +as int,created: null == created ? _self._created : created // ignore: cast_nullable_to_non_nullable +as List,updated: null == updated ? _self._updated : updated // ignore: cast_nullable_to_non_nullable +as List,errors: null == errors ? _self._errors : errors // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/scan_result.g.dart b/lib/core/models/scan_result.g.dart new file mode 100644 index 0000000..70e76fa --- /dev/null +++ b/lib/core/models/scan_result.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scan_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ScanResult _$ScanResultFromJson(Map json) => _ScanResult( + scannedPaths: + (json['scanned_paths'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + found: (json['found'] as num?)?.toInt() ?? 0, + created: + (json['created'] as List?) + ?.map((e) => ServiceScanInfo.fromJson(e as Map)) + .toList() ?? + const [], + updated: + (json['updated'] as List?) + ?.map((e) => ServiceScanInfo.fromJson(e as Map)) + .toList() ?? + const [], + errors: + (json['errors'] as List?)?.map((e) => e as String).toList() ?? + const [], +); + +Map _$ScanResultToJson(_ScanResult instance) => + { + 'scanned_paths': instance.scannedPaths, + 'found': instance.found, + 'created': instance.created.map((e) => e.toJson()).toList(), + 'updated': instance.updated.map((e) => e.toJson()).toList(), + 'errors': instance.errors, + }; diff --git a/lib/core/models/service.dart b/lib/core/models/service.dart new file mode 100644 index 0000000..72319a2 --- /dev/null +++ b/lib/core/models/service.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'backup_config.dart'; + +part 'service.freezed.dart'; +part 'service.g.dart'; + +/// Mirrors `models.Service` — a compose-based service on this host. +/// `status` is one of "up" / "down" / "unknown" (untyped string on the Go +/// side); kept as a raw string here too and mapped to [Severity] at the +/// widget boundary rather than modeled as a closed enum, since the server +/// can emit values this client doesn't yet know about. +@freezed +sealed class Service with _$Service { + const factory Service({ + @Default('') String id, + required String name, + @JsonKey(name: 'compose_file') required String composeFile, + @JsonKey(name: 'compose_type') String? composeType, + @JsonKey(name: 'backup_folder') String? backupFolder, + @Default('unknown') String status, + @JsonKey(name: 'stop_on_backup') @Default(false) bool stopOnBackup, + @JsonKey(name: 'external_backup_stop_minutes') @Default(0) int externalBackupStopMinutes, + @JsonKey(name: 'external_backup_start_minutes') @Default(0) int externalBackupStartMinutes, + BackupConfig? backup, + }) = _Service; + + factory Service.fromJson(Map json) => _$ServiceFromJson(json); +} diff --git a/lib/core/models/service.freezed.dart b/lib/core/models/service.freezed.dart new file mode 100644 index 0000000..7276514 --- /dev/null +++ b/lib/core/models/service.freezed.dart @@ -0,0 +1,322 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'service.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Service { + + String get id; String get name;@JsonKey(name: 'compose_file') String get composeFile;@JsonKey(name: 'compose_type') String? get composeType;@JsonKey(name: 'backup_folder') String? get backupFolder; String get status;@JsonKey(name: 'stop_on_backup') bool get stopOnBackup;@JsonKey(name: 'external_backup_stop_minutes') int get externalBackupStopMinutes;@JsonKey(name: 'external_backup_start_minutes') int get externalBackupStartMinutes; BackupConfig? get backup; +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ServiceCopyWith get copyWith => _$ServiceCopyWithImpl(this as Service, _$identity); + + /// Serializes this Service to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Service&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.composeType, composeType) || other.composeType == composeType)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)&&(identical(other.stopOnBackup, stopOnBackup) || other.stopOnBackup == stopOnBackup)&&(identical(other.externalBackupStopMinutes, externalBackupStopMinutes) || other.externalBackupStopMinutes == externalBackupStopMinutes)&&(identical(other.externalBackupStartMinutes, externalBackupStartMinutes) || other.externalBackupStartMinutes == externalBackupStartMinutes)&&(identical(other.backup, backup) || other.backup == backup)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,composeFile,composeType,backupFolder,status,stopOnBackup,externalBackupStopMinutes,externalBackupStartMinutes,backup); + +@override +String toString() { + return 'Service(id: $id, name: $name, composeFile: $composeFile, composeType: $composeType, backupFolder: $backupFolder, status: $status, stopOnBackup: $stopOnBackup, externalBackupStopMinutes: $externalBackupStopMinutes, externalBackupStartMinutes: $externalBackupStartMinutes, backup: $backup)'; +} + + +} + +/// @nodoc +abstract mixin class $ServiceCopyWith<$Res> { + factory $ServiceCopyWith(Service value, $Res Function(Service) _then) = _$ServiceCopyWithImpl; +@useResult +$Res call({ + String id, String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'compose_type') String? composeType,@JsonKey(name: 'backup_folder') String? backupFolder, String status,@JsonKey(name: 'stop_on_backup') bool stopOnBackup,@JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes,@JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup +}); + + +$BackupConfigCopyWith<$Res>? get backup; + +} +/// @nodoc +class _$ServiceCopyWithImpl<$Res> + implements $ServiceCopyWith<$Res> { + _$ServiceCopyWithImpl(this._self, this._then); + + final Service _self; + final $Res Function(Service) _then; + +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? composeFile = null,Object? composeType = freezed,Object? backupFolder = freezed,Object? status = null,Object? stopOnBackup = null,Object? externalBackupStopMinutes = null,Object? externalBackupStartMinutes = null,Object? backup = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable +as String,composeType: freezed == composeType ? _self.composeType : composeType // ignore: cast_nullable_to_non_nullable +as String?,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,stopOnBackup: null == stopOnBackup ? _self.stopOnBackup : stopOnBackup // ignore: cast_nullable_to_non_nullable +as bool,externalBackupStopMinutes: null == externalBackupStopMinutes ? _self.externalBackupStopMinutes : externalBackupStopMinutes // ignore: cast_nullable_to_non_nullable +as int,externalBackupStartMinutes: null == externalBackupStartMinutes ? _self.externalBackupStartMinutes : externalBackupStartMinutes // ignore: cast_nullable_to_non_nullable +as int,backup: freezed == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable +as BackupConfig?, + )); +} +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BackupConfigCopyWith<$Res>? get backup { + if (_self.backup == null) { + return null; + } + + return $BackupConfigCopyWith<$Res>(_self.backup!, (value) { + return _then(_self.copyWith(backup: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [Service]. +extension ServicePatterns on Service { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Service value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Service() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Service value) $default,){ +final _that = this; +switch (_that) { +case _Service(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Service value)? $default,){ +final _that = this; +switch (_that) { +case _Service() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Service() when $default != null: +return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup) $default,) {final _that = this; +switch (_that) { +case _Service(): +return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'compose_type') String? composeType, @JsonKey(name: 'backup_folder') String? backupFolder, String status, @JsonKey(name: 'stop_on_backup') bool stopOnBackup, @JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes, @JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup)? $default,) {final _that = this; +switch (_that) { +case _Service() when $default != null: +return $default(_that.id,_that.name,_that.composeFile,_that.composeType,_that.backupFolder,_that.status,_that.stopOnBackup,_that.externalBackupStopMinutes,_that.externalBackupStartMinutes,_that.backup);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Service implements Service { + const _Service({this.id = '', required this.name, @JsonKey(name: 'compose_file') required this.composeFile, @JsonKey(name: 'compose_type') this.composeType, @JsonKey(name: 'backup_folder') this.backupFolder, this.status = 'unknown', @JsonKey(name: 'stop_on_backup') this.stopOnBackup = false, @JsonKey(name: 'external_backup_stop_minutes') this.externalBackupStopMinutes = 0, @JsonKey(name: 'external_backup_start_minutes') this.externalBackupStartMinutes = 0, this.backup}); + factory _Service.fromJson(Map json) => _$ServiceFromJson(json); + +@override@JsonKey() final String id; +@override final String name; +@override@JsonKey(name: 'compose_file') final String composeFile; +@override@JsonKey(name: 'compose_type') final String? composeType; +@override@JsonKey(name: 'backup_folder') final String? backupFolder; +@override@JsonKey() final String status; +@override@JsonKey(name: 'stop_on_backup') final bool stopOnBackup; +@override@JsonKey(name: 'external_backup_stop_minutes') final int externalBackupStopMinutes; +@override@JsonKey(name: 'external_backup_start_minutes') final int externalBackupStartMinutes; +@override final BackupConfig? backup; + +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ServiceCopyWith<_Service> get copyWith => __$ServiceCopyWithImpl<_Service>(this, _$identity); + +@override +Map toJson() { + return _$ServiceToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Service&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.composeType, composeType) || other.composeType == composeType)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)&&(identical(other.stopOnBackup, stopOnBackup) || other.stopOnBackup == stopOnBackup)&&(identical(other.externalBackupStopMinutes, externalBackupStopMinutes) || other.externalBackupStopMinutes == externalBackupStopMinutes)&&(identical(other.externalBackupStartMinutes, externalBackupStartMinutes) || other.externalBackupStartMinutes == externalBackupStartMinutes)&&(identical(other.backup, backup) || other.backup == backup)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,composeFile,composeType,backupFolder,status,stopOnBackup,externalBackupStopMinutes,externalBackupStartMinutes,backup); + +@override +String toString() { + return 'Service(id: $id, name: $name, composeFile: $composeFile, composeType: $composeType, backupFolder: $backupFolder, status: $status, stopOnBackup: $stopOnBackup, externalBackupStopMinutes: $externalBackupStopMinutes, externalBackupStartMinutes: $externalBackupStartMinutes, backup: $backup)'; +} + + +} + +/// @nodoc +abstract mixin class _$ServiceCopyWith<$Res> implements $ServiceCopyWith<$Res> { + factory _$ServiceCopyWith(_Service value, $Res Function(_Service) _then) = __$ServiceCopyWithImpl; +@override @useResult +$Res call({ + String id, String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'compose_type') String? composeType,@JsonKey(name: 'backup_folder') String? backupFolder, String status,@JsonKey(name: 'stop_on_backup') bool stopOnBackup,@JsonKey(name: 'external_backup_stop_minutes') int externalBackupStopMinutes,@JsonKey(name: 'external_backup_start_minutes') int externalBackupStartMinutes, BackupConfig? backup +}); + + +@override $BackupConfigCopyWith<$Res>? get backup; + +} +/// @nodoc +class __$ServiceCopyWithImpl<$Res> + implements _$ServiceCopyWith<$Res> { + __$ServiceCopyWithImpl(this._self, this._then); + + final _Service _self; + final $Res Function(_Service) _then; + +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? composeFile = null,Object? composeType = freezed,Object? backupFolder = freezed,Object? status = null,Object? stopOnBackup = null,Object? externalBackupStopMinutes = null,Object? externalBackupStartMinutes = null,Object? backup = freezed,}) { + return _then(_Service( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable +as String,composeType: freezed == composeType ? _self.composeType : composeType // ignore: cast_nullable_to_non_nullable +as String?,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String,stopOnBackup: null == stopOnBackup ? _self.stopOnBackup : stopOnBackup // ignore: cast_nullable_to_non_nullable +as bool,externalBackupStopMinutes: null == externalBackupStopMinutes ? _self.externalBackupStopMinutes : externalBackupStopMinutes // ignore: cast_nullable_to_non_nullable +as int,externalBackupStartMinutes: null == externalBackupStartMinutes ? _self.externalBackupStartMinutes : externalBackupStartMinutes // ignore: cast_nullable_to_non_nullable +as int,backup: freezed == backup ? _self.backup : backup // ignore: cast_nullable_to_non_nullable +as BackupConfig?, + )); +} + +/// Create a copy of Service +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BackupConfigCopyWith<$Res>? get backup { + if (_self.backup == null) { + return null; + } + + return $BackupConfigCopyWith<$Res>(_self.backup!, (value) { + return _then(_self.copyWith(backup: value)); + }); +} +} + +// dart format on diff --git a/lib/core/models/service.g.dart b/lib/core/models/service.g.dart new file mode 100644 index 0000000..eabb9b6 --- /dev/null +++ b/lib/core/models/service.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'service.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Service _$ServiceFromJson(Map json) => _Service( + id: json['id'] as String? ?? '', + name: json['name'] as String, + composeFile: json['compose_file'] as String, + composeType: json['compose_type'] as String?, + backupFolder: json['backup_folder'] as String?, + status: json['status'] as String? ?? 'unknown', + stopOnBackup: json['stop_on_backup'] as bool? ?? false, + externalBackupStopMinutes: + (json['external_backup_stop_minutes'] as num?)?.toInt() ?? 0, + externalBackupStartMinutes: + (json['external_backup_start_minutes'] as num?)?.toInt() ?? 0, + backup: json['backup'] == null + ? null + : BackupConfig.fromJson(json['backup'] as Map), +); + +Map _$ServiceToJson(_Service instance) => { + 'id': instance.id, + 'name': instance.name, + 'compose_file': instance.composeFile, + 'compose_type': instance.composeType, + 'backup_folder': instance.backupFolder, + 'status': instance.status, + 'stop_on_backup': instance.stopOnBackup, + 'external_backup_stop_minutes': instance.externalBackupStopMinutes, + 'external_backup_start_minutes': instance.externalBackupStartMinutes, + 'backup': instance.backup?.toJson(), +}; diff --git a/lib/core/models/service_scan_info.dart b/lib/core/models/service_scan_info.dart new file mode 100644 index 0000000..074e358 --- /dev/null +++ b/lib/core/models/service_scan_info.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'service_scan_info.freezed.dart'; +part 'service_scan_info.g.dart'; + +/// Mirrors `models.ServiceScanInfo` — one entry in a [ScanResult]'s +/// `created`/`updated` lists. +@freezed +sealed class ServiceScanInfo with _$ServiceScanInfo { + const factory ServiceScanInfo({ + required String name, + @JsonKey(name: 'compose_file') required String composeFile, + @JsonKey(name: 'backup_folder') String? backupFolder, + @Default('unknown') String status, + }) = _ServiceScanInfo; + + factory ServiceScanInfo.fromJson(Map json) => _$ServiceScanInfoFromJson(json); +} diff --git a/lib/core/models/service_scan_info.freezed.dart b/lib/core/models/service_scan_info.freezed.dart new file mode 100644 index 0000000..603aa69 --- /dev/null +++ b/lib/core/models/service_scan_info.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'service_scan_info.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ServiceScanInfo { + + String get name;@JsonKey(name: 'compose_file') String get composeFile;@JsonKey(name: 'backup_folder') String? get backupFolder; String get status; +/// Create a copy of ServiceScanInfo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ServiceScanInfoCopyWith get copyWith => _$ServiceScanInfoCopyWithImpl(this as ServiceScanInfo, _$identity); + + /// Serializes this ServiceScanInfo to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ServiceScanInfo&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,composeFile,backupFolder,status); + +@override +String toString() { + return 'ServiceScanInfo(name: $name, composeFile: $composeFile, backupFolder: $backupFolder, status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class $ServiceScanInfoCopyWith<$Res> { + factory $ServiceScanInfoCopyWith(ServiceScanInfo value, $Res Function(ServiceScanInfo) _then) = _$ServiceScanInfoCopyWithImpl; +@useResult +$Res call({ + String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'backup_folder') String? backupFolder, String status +}); + + + + +} +/// @nodoc +class _$ServiceScanInfoCopyWithImpl<$Res> + implements $ServiceScanInfoCopyWith<$Res> { + _$ServiceScanInfoCopyWithImpl(this._self, this._then); + + final ServiceScanInfo _self; + final $Res Function(ServiceScanInfo) _then; + +/// Create a copy of ServiceScanInfo +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? composeFile = null,Object? backupFolder = freezed,Object? status = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable +as String,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ServiceScanInfo]. +extension ServiceScanInfoPatterns on ServiceScanInfo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ServiceScanInfo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ServiceScanInfo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ServiceScanInfo value) $default,){ +final _that = this; +switch (_that) { +case _ServiceScanInfo(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ServiceScanInfo value)? $default,){ +final _that = this; +switch (_that) { +case _ServiceScanInfo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ServiceScanInfo() when $default != null: +return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status) $default,) {final _that = this; +switch (_that) { +case _ServiceScanInfo(): +return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, @JsonKey(name: 'compose_file') String composeFile, @JsonKey(name: 'backup_folder') String? backupFolder, String status)? $default,) {final _that = this; +switch (_that) { +case _ServiceScanInfo() when $default != null: +return $default(_that.name,_that.composeFile,_that.backupFolder,_that.status);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ServiceScanInfo implements ServiceScanInfo { + const _ServiceScanInfo({required this.name, @JsonKey(name: 'compose_file') required this.composeFile, @JsonKey(name: 'backup_folder') this.backupFolder, this.status = 'unknown'}); + factory _ServiceScanInfo.fromJson(Map json) => _$ServiceScanInfoFromJson(json); + +@override final String name; +@override@JsonKey(name: 'compose_file') final String composeFile; +@override@JsonKey(name: 'backup_folder') final String? backupFolder; +@override@JsonKey() final String status; + +/// Create a copy of ServiceScanInfo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ServiceScanInfoCopyWith<_ServiceScanInfo> get copyWith => __$ServiceScanInfoCopyWithImpl<_ServiceScanInfo>(this, _$identity); + +@override +Map toJson() { + return _$ServiceScanInfoToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServiceScanInfo&&(identical(other.name, name) || other.name == name)&&(identical(other.composeFile, composeFile) || other.composeFile == composeFile)&&(identical(other.backupFolder, backupFolder) || other.backupFolder == backupFolder)&&(identical(other.status, status) || other.status == status)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,composeFile,backupFolder,status); + +@override +String toString() { + return 'ServiceScanInfo(name: $name, composeFile: $composeFile, backupFolder: $backupFolder, status: $status)'; +} + + +} + +/// @nodoc +abstract mixin class _$ServiceScanInfoCopyWith<$Res> implements $ServiceScanInfoCopyWith<$Res> { + factory _$ServiceScanInfoCopyWith(_ServiceScanInfo value, $Res Function(_ServiceScanInfo) _then) = __$ServiceScanInfoCopyWithImpl; +@override @useResult +$Res call({ + String name,@JsonKey(name: 'compose_file') String composeFile,@JsonKey(name: 'backup_folder') String? backupFolder, String status +}); + + + + +} +/// @nodoc +class __$ServiceScanInfoCopyWithImpl<$Res> + implements _$ServiceScanInfoCopyWith<$Res> { + __$ServiceScanInfoCopyWithImpl(this._self, this._then); + + final _ServiceScanInfo _self; + final $Res Function(_ServiceScanInfo) _then; + +/// Create a copy of ServiceScanInfo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? composeFile = null,Object? backupFolder = freezed,Object? status = null,}) { + return _then(_ServiceScanInfo( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,composeFile: null == composeFile ? _self.composeFile : composeFile // ignore: cast_nullable_to_non_nullable +as String,backupFolder: freezed == backupFolder ? _self.backupFolder : backupFolder // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/service_scan_info.g.dart b/lib/core/models/service_scan_info.g.dart new file mode 100644 index 0000000..2848382 --- /dev/null +++ b/lib/core/models/service_scan_info.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'service_scan_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ServiceScanInfo _$ServiceScanInfoFromJson(Map json) => + _ServiceScanInfo( + name: json['name'] as String, + composeFile: json['compose_file'] as String, + backupFolder: json['backup_folder'] as String?, + status: json['status'] as String? ?? 'unknown', + ); + +Map _$ServiceScanInfoToJson(_ServiceScanInfo instance) => + { + 'name': instance.name, + 'compose_file': instance.composeFile, + 'backup_folder': instance.backupFolder, + 'status': instance.status, + }; diff --git a/lib/core/models/severity_mapping.dart b/lib/core/models/severity_mapping.dart new file mode 100644 index 0000000..01faf9e --- /dev/null +++ b/lib/core/models/severity_mapping.dart @@ -0,0 +1,16 @@ +import '../theme/status_colors.dart'; + +/// Maps the API's untyped status strings to a [Severity] for [StatusPill] +/// rendering. Centralized here (not per-screen) since `Service.status` is +/// rendered on both the Services table and Fleet's aggregated card grid. +Severity severityForServiceStatus(String status) => switch (status) { + 'up' => Severity.good, + 'down' => Severity.critical, + _ => Severity.warning, // "unknown" or anything this client doesn't recognize yet +}; + +/// Maps a backup/update execution's `success` flag to a [Severity]. +Severity severityForSuccess(bool success) => success ? Severity.good : Severity.critical; + +/// Maps a [RemoteNode.status]/reachability signal to a [Severity]. +Severity severityForNodeReachable(bool reachable) => reachable ? Severity.good : Severity.critical; diff --git a/lib/core/models/update_record.dart b/lib/core/models/update_record.dart new file mode 100644 index 0000000..36c651c --- /dev/null +++ b/lib/core/models/update_record.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'update_record.freezed.dart'; +part 'update_record.g.dart'; + +/// Mirrors `models.UpdateRecord` — an OS package update log entry. +@freezed +sealed class UpdateRecord with _$UpdateRecord { + const factory UpdateRecord({ + @Default('') String id, + required DateTime timestamp, + required bool success, + @Default([]) List packages, + @Default('') String message, + }) = _UpdateRecord; + + factory UpdateRecord.fromJson(Map json) => _$UpdateRecordFromJson(json); +} diff --git a/lib/core/models/update_record.freezed.dart b/lib/core/models/update_record.freezed.dart new file mode 100644 index 0000000..482498a --- /dev/null +++ b/lib/core/models/update_record.freezed.dart @@ -0,0 +1,289 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'update_record.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$UpdateRecord { + + String get id; DateTime get timestamp; bool get success; List get packages; String get message; +/// Create a copy of UpdateRecord +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$UpdateRecordCopyWith get copyWith => _$UpdateRecordCopyWithImpl(this as UpdateRecord, _$identity); + + /// Serializes this UpdateRecord to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&const DeepCollectionEquality().equals(other.packages, packages)&&(identical(other.message, message) || other.message == message)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,success,const DeepCollectionEquality().hash(packages),message); + +@override +String toString() { + return 'UpdateRecord(id: $id, timestamp: $timestamp, success: $success, packages: $packages, message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $UpdateRecordCopyWith<$Res> { + factory $UpdateRecordCopyWith(UpdateRecord value, $Res Function(UpdateRecord) _then) = _$UpdateRecordCopyWithImpl; +@useResult +$Res call({ + String id, DateTime timestamp, bool success, List packages, String message +}); + + + + +} +/// @nodoc +class _$UpdateRecordCopyWithImpl<$Res> + implements $UpdateRecordCopyWith<$Res> { + _$UpdateRecordCopyWithImpl(this._self, this._then); + + final UpdateRecord _self; + final $Res Function(UpdateRecord) _then; + +/// Create a copy of UpdateRecord +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? packages = null,Object? message = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,packages: null == packages ? _self.packages : packages // ignore: cast_nullable_to_non_nullable +as List,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [UpdateRecord]. +extension UpdateRecordPatterns on UpdateRecord { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _UpdateRecord value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _UpdateRecord() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _UpdateRecord value) $default,){ +final _that = this; +switch (_that) { +case _UpdateRecord(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _UpdateRecord value)? $default,){ +final _that = this; +switch (_that) { +case _UpdateRecord() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, DateTime timestamp, bool success, List packages, String message)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _UpdateRecord() when $default != null: +return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, DateTime timestamp, bool success, List packages, String message) $default,) {final _that = this; +switch (_that) { +case _UpdateRecord(): +return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, DateTime timestamp, bool success, List packages, String message)? $default,) {final _that = this; +switch (_that) { +case _UpdateRecord() when $default != null: +return $default(_that.id,_that.timestamp,_that.success,_that.packages,_that.message);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _UpdateRecord implements UpdateRecord { + const _UpdateRecord({this.id = '', required this.timestamp, required this.success, final List packages = const [], this.message = ''}): _packages = packages; + factory _UpdateRecord.fromJson(Map json) => _$UpdateRecordFromJson(json); + +@override@JsonKey() final String id; +@override final DateTime timestamp; +@override final bool success; + final List _packages; +@override@JsonKey() List get packages { + if (_packages is EqualUnmodifiableListView) return _packages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_packages); +} + +@override@JsonKey() final String message; + +/// Create a copy of UpdateRecord +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$UpdateRecordCopyWith<_UpdateRecord> get copyWith => __$UpdateRecordCopyWithImpl<_UpdateRecord>(this, _$identity); + +@override +Map toJson() { + return _$UpdateRecordToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateRecord&&(identical(other.id, id) || other.id == id)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.success, success) || other.success == success)&&const DeepCollectionEquality().equals(other._packages, _packages)&&(identical(other.message, message) || other.message == message)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,timestamp,success,const DeepCollectionEquality().hash(_packages),message); + +@override +String toString() { + return 'UpdateRecord(id: $id, timestamp: $timestamp, success: $success, packages: $packages, message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class _$UpdateRecordCopyWith<$Res> implements $UpdateRecordCopyWith<$Res> { + factory _$UpdateRecordCopyWith(_UpdateRecord value, $Res Function(_UpdateRecord) _then) = __$UpdateRecordCopyWithImpl; +@override @useResult +$Res call({ + String id, DateTime timestamp, bool success, List packages, String message +}); + + + + +} +/// @nodoc +class __$UpdateRecordCopyWithImpl<$Res> + implements _$UpdateRecordCopyWith<$Res> { + __$UpdateRecordCopyWithImpl(this._self, this._then); + + final _UpdateRecord _self; + final $Res Function(_UpdateRecord) _then; + +/// Create a copy of UpdateRecord +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? timestamp = null,Object? success = null,Object? packages = null,Object? message = null,}) { + return _then(_UpdateRecord( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime,success: null == success ? _self.success : success // ignore: cast_nullable_to_non_nullable +as bool,packages: null == packages ? _self._packages : packages // ignore: cast_nullable_to_non_nullable +as List,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/models/update_record.g.dart b/lib/core/models/update_record.g.dart new file mode 100644 index 0000000..29f0eed --- /dev/null +++ b/lib/core/models/update_record.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'update_record.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_UpdateRecord _$UpdateRecordFromJson(Map json) => + _UpdateRecord( + id: json['id'] as String? ?? '', + timestamp: DateTime.parse(json['timestamp'] as String), + success: json['success'] as bool, + packages: + (json['packages'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + message: json['message'] as String? ?? '', + ); + +Map _$UpdateRecordToJson(_UpdateRecord instance) => + { + 'id': instance.id, + 'timestamp': instance.timestamp.toIso8601String(), + 'success': instance.success, + 'packages': instance.packages, + 'message': instance.message, + }; diff --git a/lib/core/network/api_client_provider.dart b/lib/core/network/api_client_provider.dart new file mode 100644 index 0000000..2f16df4 --- /dev/null +++ b/lib/core/network/api_client_provider.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../connections/connection_providers.dart'; +import 'node_master_api_client.dart'; + +part 'api_client_provider.g.dart'; + +/// Rebuilt (not mutated) whenever the active connection changes, so every +/// watcher automatically rebuilds against the new host with no manual +/// invalidation elsewhere. Returns null when no connection is configured — +/// `app_router.dart` redirects to Settings in that case rather than every +/// screen needing its own "no connection" branch. +@riverpod +NodeMasterApiClient? apiClient(Ref ref) { + final connection = ref.watch(activeConnectionProvider); + if (connection == null) return null; + + final cancelToken = CancelToken(); + ref.onDispose(() => cancelToken.cancel('Active connection changed.')); + + final baseUrl = connection.baseUrl.endsWith('/') + ? connection.baseUrl.substring(0, connection.baseUrl.length - 1) + : connection.baseUrl; + + final dio = Dio( + BaseOptions( + baseUrl: '$baseUrl/v1', + connectTimeout: const Duration(seconds: 8), + sendTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + contentType: 'application/json', + ), + ); + if (kDebugMode) { + dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true)); + } + + return NodeMasterApiClient(dio, cancelToken); +} diff --git a/lib/core/network/api_client_provider.g.dart b/lib/core/network/api_client_provider.g.dart new file mode 100644 index 0000000..9bb866e --- /dev/null +++ b/lib/core/network/api_client_provider.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_client_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Rebuilt (not mutated) whenever the active connection changes, so every +/// watcher automatically rebuilds against the new host with no manual +/// invalidation elsewhere. Returns null when no connection is configured — +/// `app_router.dart` redirects to Settings in that case rather than every +/// screen needing its own "no connection" branch. + +@ProviderFor(apiClient) +const apiClientProvider = ApiClientProvider._(); + +/// Rebuilt (not mutated) whenever the active connection changes, so every +/// watcher automatically rebuilds against the new host with no manual +/// invalidation elsewhere. Returns null when no connection is configured — +/// `app_router.dart` redirects to Settings in that case rather than every +/// screen needing its own "no connection" branch. + +final class ApiClientProvider + extends + $FunctionalProvider< + NodeMasterApiClient?, + NodeMasterApiClient?, + NodeMasterApiClient? + > + with $Provider { + /// Rebuilt (not mutated) whenever the active connection changes, so every + /// watcher automatically rebuilds against the new host with no manual + /// invalidation elsewhere. Returns null when no connection is configured — + /// `app_router.dart` redirects to Settings in that case rather than every + /// screen needing its own "no connection" branch. + const ApiClientProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'apiClientProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$apiClientHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NodeMasterApiClient? create(Ref ref) { + return apiClient(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NodeMasterApiClient? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$apiClientHash() => r'0a33d0449a51fd825f43bdcddd686bd413ff74ea'; diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart new file mode 100644 index 0000000..bdf3b06 --- /dev/null +++ b/lib/core/network/api_exception.dart @@ -0,0 +1,44 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'api_exception.freezed.dart'; + +/// Typed errors thrown by [NodeMasterApiClient]/repositories. Riverpod's +/// `AsyncValue` (via `FutureProvider`/`AsyncNotifier`) already catches +/// thrown exceptions into `AsyncValue.error`, so this is the result-type +/// boundary — no separate `Result` wrapper is layered on top. +@freezed +sealed class ApiException with _$ApiException implements Exception { + /// DNS/connection failure — couldn't reach the host at all. + const factory ApiException.network(String message) = ApiNetworkException; + + /// Connect/send/receive timeout. + const factory ApiException.timeout() = ApiTimeoutException; + + /// The server responded with a 4xx/5xx and (usually) a `{"error": msg}` + /// body. + const factory ApiException.server(int statusCode, String message) = ApiServerException; + + /// The response body wasn't the JSON shape a model expected. + const factory ApiException.parse(String message) = ApiParseException; + + /// Request was cancelled (e.g. the active connection changed mid-flight). + const factory ApiException.cancelled() = ApiCancelledException; + + /// Anything else. + const factory ApiException.unknown(String message) = ApiUnknownException; +} + +extension ApiExceptionUserMessage on ApiException { + /// Short, user-facing copy — screens can use this directly or switch on + /// the exception type themselves for more specific handling. + String get userMessage => switch (this) { + ApiNetworkException() => "Can't reach this node — check the URL and that it's reachable.", + ApiTimeoutException() => 'The node took too long to respond.', + ApiServerException(:final statusCode, :final message) => statusCode == 404 + ? 'Not found.' + : 'Server error ($statusCode): $message', + ApiParseException() => 'Received an unexpected response from the node.', + ApiCancelledException() => 'Cancelled.', + ApiUnknownException(:final message) => message, + }; +} diff --git a/lib/core/network/api_exception.freezed.dart b/lib/core/network/api_exception.freezed.dart new file mode 100644 index 0000000..cc34327 --- /dev/null +++ b/lib/core/network/api_exception.freezed.dart @@ -0,0 +1,528 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'api_exception.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ApiException { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiException); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ApiException()'; +} + + +} + +/// @nodoc +class $ApiExceptionCopyWith<$Res> { +$ApiExceptionCopyWith(ApiException _, $Res Function(ApiException) __); +} + + +/// Adds pattern-matching-related methods to [ApiException]. +extension ApiExceptionPatterns on ApiException { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( ApiNetworkException value)? network,TResult Function( ApiTimeoutException value)? timeout,TResult Function( ApiServerException value)? server,TResult Function( ApiParseException value)? parse,TResult Function( ApiCancelledException value)? cancelled,TResult Function( ApiUnknownException value)? unknown,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case ApiNetworkException() when network != null: +return network(_that);case ApiTimeoutException() when timeout != null: +return timeout(_that);case ApiServerException() when server != null: +return server(_that);case ApiParseException() when parse != null: +return parse(_that);case ApiCancelledException() when cancelled != null: +return cancelled(_that);case ApiUnknownException() when unknown != null: +return unknown(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( ApiNetworkException value) network,required TResult Function( ApiTimeoutException value) timeout,required TResult Function( ApiServerException value) server,required TResult Function( ApiParseException value) parse,required TResult Function( ApiCancelledException value) cancelled,required TResult Function( ApiUnknownException value) unknown,}){ +final _that = this; +switch (_that) { +case ApiNetworkException(): +return network(_that);case ApiTimeoutException(): +return timeout(_that);case ApiServerException(): +return server(_that);case ApiParseException(): +return parse(_that);case ApiCancelledException(): +return cancelled(_that);case ApiUnknownException(): +return unknown(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( ApiNetworkException value)? network,TResult? Function( ApiTimeoutException value)? timeout,TResult? Function( ApiServerException value)? server,TResult? Function( ApiParseException value)? parse,TResult? Function( ApiCancelledException value)? cancelled,TResult? Function( ApiUnknownException value)? unknown,}){ +final _that = this; +switch (_that) { +case ApiNetworkException() when network != null: +return network(_that);case ApiTimeoutException() when timeout != null: +return timeout(_that);case ApiServerException() when server != null: +return server(_that);case ApiParseException() when parse != null: +return parse(_that);case ApiCancelledException() when cancelled != null: +return cancelled(_that);case ApiUnknownException() when unknown != null: +return unknown(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String message)? network,TResult Function()? timeout,TResult Function( int statusCode, String message)? server,TResult Function( String message)? parse,TResult Function()? cancelled,TResult Function( String message)? unknown,required TResult orElse(),}) {final _that = this; +switch (_that) { +case ApiNetworkException() when network != null: +return network(_that.message);case ApiTimeoutException() when timeout != null: +return timeout();case ApiServerException() when server != null: +return server(_that.statusCode,_that.message);case ApiParseException() when parse != null: +return parse(_that.message);case ApiCancelledException() when cancelled != null: +return cancelled();case ApiUnknownException() when unknown != null: +return unknown(_that.message);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String message) network,required TResult Function() timeout,required TResult Function( int statusCode, String message) server,required TResult Function( String message) parse,required TResult Function() cancelled,required TResult Function( String message) unknown,}) {final _that = this; +switch (_that) { +case ApiNetworkException(): +return network(_that.message);case ApiTimeoutException(): +return timeout();case ApiServerException(): +return server(_that.statusCode,_that.message);case ApiParseException(): +return parse(_that.message);case ApiCancelledException(): +return cancelled();case ApiUnknownException(): +return unknown(_that.message);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String message)? network,TResult? Function()? timeout,TResult? Function( int statusCode, String message)? server,TResult? Function( String message)? parse,TResult? Function()? cancelled,TResult? Function( String message)? unknown,}) {final _that = this; +switch (_that) { +case ApiNetworkException() when network != null: +return network(_that.message);case ApiTimeoutException() when timeout != null: +return timeout();case ApiServerException() when server != null: +return server(_that.statusCode,_that.message);case ApiParseException() when parse != null: +return parse(_that.message);case ApiCancelledException() when cancelled != null: +return cancelled();case ApiUnknownException() when unknown != null: +return unknown(_that.message);case _: + return null; + +} +} + +} + +/// @nodoc + + +class ApiNetworkException implements ApiException { + const ApiNetworkException(this.message); + + + final String message; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApiNetworkExceptionCopyWith get copyWith => _$ApiNetworkExceptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiNetworkException&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'ApiException.network(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $ApiNetworkExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> { + factory $ApiNetworkExceptionCopyWith(ApiNetworkException value, $Res Function(ApiNetworkException) _then) = _$ApiNetworkExceptionCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$ApiNetworkExceptionCopyWithImpl<$Res> + implements $ApiNetworkExceptionCopyWith<$Res> { + _$ApiNetworkExceptionCopyWithImpl(this._self, this._then); + + final ApiNetworkException _self; + final $Res Function(ApiNetworkException) _then; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(ApiNetworkException( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ApiTimeoutException implements ApiException { + const ApiTimeoutException(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiTimeoutException); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ApiException.timeout()'; +} + + +} + + + + +/// @nodoc + + +class ApiServerException implements ApiException { + const ApiServerException(this.statusCode, this.message); + + + final int statusCode; + final String message; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApiServerExceptionCopyWith get copyWith => _$ApiServerExceptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiServerException&&(identical(other.statusCode, statusCode) || other.statusCode == statusCode)&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,statusCode,message); + +@override +String toString() { + return 'ApiException.server(statusCode: $statusCode, message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $ApiServerExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> { + factory $ApiServerExceptionCopyWith(ApiServerException value, $Res Function(ApiServerException) _then) = _$ApiServerExceptionCopyWithImpl; +@useResult +$Res call({ + int statusCode, String message +}); + + + + +} +/// @nodoc +class _$ApiServerExceptionCopyWithImpl<$Res> + implements $ApiServerExceptionCopyWith<$Res> { + _$ApiServerExceptionCopyWithImpl(this._self, this._then); + + final ApiServerException _self; + final $Res Function(ApiServerException) _then; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? statusCode = null,Object? message = null,}) { + return _then(ApiServerException( +null == statusCode ? _self.statusCode : statusCode // ignore: cast_nullable_to_non_nullable +as int,null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ApiParseException implements ApiException { + const ApiParseException(this.message); + + + final String message; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApiParseExceptionCopyWith get copyWith => _$ApiParseExceptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiParseException&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'ApiException.parse(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $ApiParseExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> { + factory $ApiParseExceptionCopyWith(ApiParseException value, $Res Function(ApiParseException) _then) = _$ApiParseExceptionCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$ApiParseExceptionCopyWithImpl<$Res> + implements $ApiParseExceptionCopyWith<$Res> { + _$ApiParseExceptionCopyWithImpl(this._self, this._then); + + final ApiParseException _self; + final $Res Function(ApiParseException) _then; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(ApiParseException( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ApiCancelledException implements ApiException { + const ApiCancelledException(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiCancelledException); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ApiException.cancelled()'; +} + + +} + + + + +/// @nodoc + + +class ApiUnknownException implements ApiException { + const ApiUnknownException(this.message); + + + final String message; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ApiUnknownExceptionCopyWith get copyWith => _$ApiUnknownExceptionCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiUnknownException&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'ApiException.unknown(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $ApiUnknownExceptionCopyWith<$Res> implements $ApiExceptionCopyWith<$Res> { + factory $ApiUnknownExceptionCopyWith(ApiUnknownException value, $Res Function(ApiUnknownException) _then) = _$ApiUnknownExceptionCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$ApiUnknownExceptionCopyWithImpl<$Res> + implements $ApiUnknownExceptionCopyWith<$Res> { + _$ApiUnknownExceptionCopyWithImpl(this._self, this._then); + + final ApiUnknownException _self; + final $Res Function(ApiUnknownException) _then; + +/// Create a copy of ApiException +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(ApiUnknownException( +null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/core/network/dio_error_mapper.dart b/lib/core/network/dio_error_mapper.dart new file mode 100644 index 0000000..9e5aecf --- /dev/null +++ b/lib/core/network/dio_error_mapper.dart @@ -0,0 +1,39 @@ +import 'package:dio/dio.dart'; + +import 'api_exception.dart'; + +/// Maps a caught [DioException] (or any other error) to a typed +/// [ApiException]. The one place this translation happens — repositories +/// and the API client both funnel through this rather than inspecting +/// `DioException` directly. +ApiException mapDioException(Object error) { + if (error is ApiException) return error; + + if (error is! DioException) { + return ApiException.unknown(error.toString()); + } + + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.transformTimeout: + return const ApiException.timeout(); + case DioExceptionType.connectionError: + return ApiException.network(error.message ?? 'Connection failed.'); + case DioExceptionType.cancel: + return const ApiException.cancelled(); + case DioExceptionType.badResponse: + final response = error.response; + final statusCode = response?.statusCode ?? 0; + final data = response?.data; + final message = data is Map && data['error'] is String + ? data['error'] as String + : (response?.statusMessage ?? 'Request failed.'); + return ApiException.server(statusCode, message); + case DioExceptionType.badCertificate: + return ApiException.network('Bad certificate: ${error.message ?? ''}'); + case DioExceptionType.unknown: + return ApiException.unknown(error.message ?? error.toString()); + } +} diff --git a/lib/core/network/node_master_api_client.dart b/lib/core/network/node_master_api_client.dart new file mode 100644 index 0000000..9407b55 --- /dev/null +++ b/lib/core/network/node_master_api_client.dart @@ -0,0 +1,261 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; + +import '../models/models.dart'; +import 'api_exception.dart'; +import 'dio_error_mapper.dart'; + +/// Thin typed wrapper over [Dio], one method per NodeMaster REST endpoint. +/// Every method funnels errors through [mapDioException] so callers only +/// ever see [ApiException]. Holds a [CancelToken] tied to the client's own +/// lifetime (cancelled by [apiClientProvider] when the active connection +/// changes), so a request from an abandoned connection can't resolve into +/// the next connection's UI state. +class NodeMasterApiClient { + NodeMasterApiClient(this._dio, this._cancelToken); + + final Dio _dio; + final CancelToken _cancelToken; + + /// The overall budget for one request, enforced by Dart's own + /// `Future.timeout` rather than relying solely on Dio's + /// `connectTimeout`/`receiveTimeout` `BaseOptions`. Dio's browser HTTP + /// adapter has no socket-level hooks to honor those (the underlying + /// `fetch`/`XMLHttpRequest` APIs don't expose a connect-phase timeout), + /// so on web a request to an unreachable host would otherwise hang + /// until the browser's own (much longer, sometimes unbounded) default — + /// this timeout is what actually bounds it on every platform. + static const _requestBudget = Duration(seconds: 20); + + Future _guard(Future Function() body) async { + try { + return await body().timeout(_requestBudget); + } on ApiException { + rethrow; + } on DioException catch (e) { + throw mapDioException(e); + } on TimeoutException { + throw const ApiException.timeout(); + } on TypeError catch (e) { + throw ApiException.parse(e.toString()); + } on FormatException catch (e) { + throw ApiException.parse(e.toString()); + } + } + + Future> _get(String path, {Map? query}) => _dio.get( + path, + queryParameters: query, + cancelToken: _cancelToken, + ); + + Future> _post(String path, {Object? data}) => _dio.post( + path, + data: data, + cancelToken: _cancelToken, + ); + + Future> _put(String path, {Object? data}) => _dio.put( + path, + data: data, + cancelToken: _cancelToken, + ); + + Future> _delete(String path) => _dio.delete(path, cancelToken: _cancelToken); + + // ---- node ---- + + Future getNode() => _guard(() async { + final res = await _get('/node/'); + return NodeInfo.fromJson(res.data as Map); + }); + + Future updateNode(NodeInfo node) => _guard(() async { + await _put('/node/', data: node.toJson()); + }); + + Future getNodeBackup() => _guard(() async { + final res = await _get('/node/backup'); + return BackupConfig.fromJson(res.data as Map); + }); + + Future updateNodeBackup(BackupConfig config) => _guard(() async { + await _put('/node/backup', data: config.toJson()); + }); + + Future runNodeBackup() => _guard(() async { + await _post('/node/backup/run'); + }); + + Future> getNodeBackupTargets() => _guard(() async { + final res = await _get('/node/backup/targets'); + return (res.data as List) + .map((e) => BackupTarget.fromJson(e as Map)) + .toList(); + }); + + Future addNodeBackupTarget(BackupTarget target) => _guard(() async { + final res = await _post('/node/backup/targets', data: target.toJson()); + return BackupTarget.fromJson(res.data as Map); + }); + + Future updateNodeBackupTarget(String targetId, BackupTarget target) => _guard(() async { + final res = await _put('/node/backup/targets/$targetId', data: target.toJson()); + return BackupTarget.fromJson(res.data as Map); + }); + + Future deleteNodeBackupTarget(String targetId) => _guard(() async { + await _delete('/node/backup/targets/$targetId'); + }); + + Future runNodeBackupTarget(String targetId) => _guard(() async { + await _post('/node/backup/targets/$targetId/run'); + }); + + Future getNodeBackupTargetProgress(String targetId) => _guard(() async { + final res = await _get('/node/backup/targets/$targetId/progress'); + return RunState.fromJson(res.data as Map); + }); + + Future> getNodeBackupProgress() => _guard(() async { + final res = await _get('/node/backup/progress'); + return (res.data as List) + .map((e) => RunState.fromJson(e as Map)) + .toList(); + }); + + Future scan({String? folder}) => _guard(() async { + final res = await _post('/node/scan', data: folder == null ? {} : {'folder': folder}); + return ScanResult.fromJson(res.data as Map); + }); + + Future> getUpdates() => _guard(() async { + final res = await _get('/node/updates'); + return (res.data as List) + .map((e) => UpdateRecord.fromJson(e as Map)) + .toList(); + }); + + Future addUpdate(UpdateRecord record) => _guard(() async { + final res = await _post('/node/updates', data: record.toJson()); + return UpdateRecord.fromJson(res.data as Map); + }); + + Future deleteUpdate(String id) => _guard(() async { + await _delete('/node/updates/$id'); + }); + + // ---- nodes (fleet registry) ---- + + Future> getAllRemoteNodes() => _guard(() async { + final res = await _get('/nodes/'); + return (res.data as List) + .map((e) => RemoteNode.fromJson(e as Map)) + .toList(); + }); + + Future addRemoteNode(RemoteNode node) => _guard(() async { + final res = await _post('/nodes/', data: node.toJson()); + return RemoteNode.fromJson(res.data as Map); + }); + + Future getRemoteNode(String id) => _guard(() async { + final res = await _get('/nodes/$id'); + return RemoteNode.fromJson(res.data as Map); + }); + + Future updateRemoteNode(String id, RemoteNode node) => _guard(() async { + final res = await _put('/nodes/$id', data: node.toJson()); + return RemoteNode.fromJson(res.data as Map); + }); + + Future deleteRemoteNode(String id) => _guard(() async { + await _delete('/nodes/$id'); + }); + + Future> getAggregated() => _guard(() async { + final res = await _get('/nodes/aggregated'); + return (res.data as List) + .map((e) => AggregatedNodeStatus.fromJson(e as Map)) + .toList(); + }); + + // ---- services ---- + + Future> getAllServices() => _guard(() async { + final res = await _get('/services/'); + return (res.data as List) + .map((e) => Service.fromJson(e as Map)) + .toList(); + }); + + Future addService(Service service) => _guard(() async { + final res = await _post('/services/', data: service.toJson()); + return Service.fromJson(res.data as Map); + }); + + Future getService(String id) => _guard(() async { + final res = await _get('/services/$id'); + return Service.fromJson(res.data as Map); + }); + + Future updateService(String id, Service service) => _guard(() async { + final res = await _put('/services/$id', data: service.toJson()); + return Service.fromJson(res.data as Map); + }); + + Future deleteService(String id) => _guard(() async { + await _delete('/services/$id'); + }); + + Future startService(String id) => _guard(() async { + await _post('/services/$id/start'); + }); + + Future stopService(String id) => _guard(() async { + await _post('/services/$id/stop'); + }); + + Future backupService(String id) => _guard(() async { + await _post('/services/$id/backup'); + }); + + Future> getServiceBackupTargets(String id) => _guard(() async { + final res = await _get('/services/$id/backup/targets'); + return (res.data as List) + .map((e) => BackupTarget.fromJson(e as Map)) + .toList(); + }); + + Future addServiceBackupTarget(String id, BackupTarget target) => _guard(() async { + final res = await _post('/services/$id/backup/targets', data: target.toJson()); + return BackupTarget.fromJson(res.data as Map); + }); + + Future updateServiceBackupTarget(String id, String targetId, BackupTarget target) => + _guard(() async { + final res = await _put('/services/$id/backup/targets/$targetId', data: target.toJson()); + return BackupTarget.fromJson(res.data as Map); + }); + + Future deleteServiceBackupTarget(String id, String targetId) => _guard(() async { + await _delete('/services/$id/backup/targets/$targetId'); + }); + + Future runServiceBackupTarget(String id, String targetId) => _guard(() async { + await _post('/services/$id/backup/targets/$targetId/run'); + }); + + Future getServiceBackupTargetProgress(String id, String targetId) => _guard(() async { + final res = await _get('/services/$id/backup/targets/$targetId/progress'); + return RunState.fromJson(res.data as Map); + }); + + Future> getServiceBackupProgress(String id) => _guard(() async { + final res = await _get('/services/$id/backup/progress'); + return (res.data as List) + .map((e) => RunState.fromJson(e as Map)) + .toList(); + }); +} diff --git a/lib/core/polling/polling_async_notifier.dart b/lib/core/polling/polling_async_notifier.dart new file mode 100644 index 0000000..9f538dd --- /dev/null +++ b/lib/core/polling/polling_async_notifier.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +/// Mixed into a `@riverpod` `AutoDisposeAsyncNotifier` to auto-refresh it on +/// a timer while it has at least one listener — Dashboard's summary and +/// Fleet's aggregated view, specifically. The API has no push/websocket, so +/// this is the honest refresh model: a manual pull plus a background +/// timer, not a live stream. +/// +/// Usage: `class FooNotifier extends _$FooNotifier with PollingMixin`, +/// implement [interval] and [fetch], and have `build()` return +/// `startPolling()`. +mixin PollingMixin on $AsyncNotifier { + /// How often to refetch while this provider has at least one listener. + Duration get interval; + + /// Performs the actual fetch. Thrown exceptions surface as + /// `AsyncValue.error` exactly like any other async provider. + Future fetch(); + + /// Call from `build()`: starts the periodic timer (cancelled on dispose) + /// and returns the initial fetch. + Future startPolling() { + final timer = Timer.periodic(interval, (_) => refresh()); + ref.onDispose(timer.cancel); + return fetch(); + } + + /// Re-fetches and updates state. Deliberately never emits an + /// intermediate `AsyncLoading` — the previous data/error stays on screen + /// until the new result lands, so a background tick never flashes the + /// UI back to a loading spinner. + Future refresh() async { + state = await AsyncValue.guard(fetch); + } +} diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart new file mode 100644 index 0000000..1933a44 --- /dev/null +++ b/lib/core/routing/app_router.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../features/backups/backups_screen.dart'; +import '../../features/dashboard/dashboard_screen.dart'; +import '../../features/fleet/fleet_screen.dart'; +import '../../features/services/services_screen.dart'; +import '../../features/settings/settings_screen.dart'; +import '../../features/updates/updates_screen.dart'; +import '../connections/connection_providers.dart'; +import '../widgets/nav_rail_shell.dart'; +import 'route_paths.dart'; + +part 'app_router.g.dart'; + +/// Bridges Riverpod's [activeConnectionProvider] to go_router's +/// [Listenable]-based `refreshListenable` — so removing/adding the active +/// connection re-evaluates the top-level redirect immediately, not just on +/// the next navigation. +class _ConnectionRefreshListenable extends ChangeNotifier { + _ConnectionRefreshListenable(Ref ref) { + ref.listen(activeConnectionProvider, (previous, next) => notifyListeners()); + } +} + +@Riverpod(keepAlive: true) +GoRouter appRouter(Ref ref) { + return GoRouter( + initialLocation: RoutePaths.dashboard, + refreshListenable: _ConnectionRefreshListenable(ref), + redirect: (context, state) { + final hasConnection = ref.read(activeConnectionProvider) != null; + final onSettings = state.matchedLocation == RoutePaths.settings; + if (!hasConnection && !onSettings) return RoutePaths.settings; + return null; + }, + routes: [ + ShellRoute( + builder: (context, state, child) { + return NavRailShell(currentPath: state.uri.path, child: child); + }, + routes: [ + GoRoute( + path: RoutePaths.dashboard, + builder: (context, state) => const DashboardScreen(), + ), + GoRoute( + path: RoutePaths.services, + builder: (context, state) => ServicesScreen(serviceId: state.uri.queryParameters['id']), + ), + GoRoute( + path: RoutePaths.backups, + builder: (context, state) => const BackupsScreen(), + ), + GoRoute( + path: RoutePaths.fleet, + builder: (context, state) => const FleetScreen(), + ), + GoRoute( + path: RoutePaths.updates, + builder: (context, state) => const UpdatesScreen(), + ), + GoRoute( + path: RoutePaths.settings, + builder: (context, state) => const SettingsScreen(), + ), + ], + ), + ], + ); +} diff --git a/lib/core/routing/app_router.g.dart b/lib/core/routing/app_router.g.dart new file mode 100644 index 0000000..80d58c9 --- /dev/null +++ b/lib/core/routing/app_router.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_router.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(appRouter) +const appRouterProvider = AppRouterProvider._(); + +final class AppRouterProvider + extends $FunctionalProvider + with $Provider { + const AppRouterProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appRouterProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appRouterHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GoRouter create(Ref ref) { + return appRouter(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GoRouter value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appRouterHash() => r'564cdc8b6bb47dea31d32b10bbfa1253170018af'; diff --git a/lib/core/routing/route_paths.dart b/lib/core/routing/route_paths.dart new file mode 100644 index 0000000..0f2673b --- /dev/null +++ b/lib/core/routing/route_paths.dart @@ -0,0 +1,17 @@ +/// Route path constants — the single source of truth so screens and the +/// nav rail never hand-type a path string more than once. +abstract final class RoutePaths { + static const dashboard = '/dashboard'; + static const services = '/services'; + static const backups = '/backups'; + static const fleet = '/fleet'; + static const updates = '/updates'; + static const settings = '/settings'; + + /// The service id is a query parameter (`?id=`), not a path segment. + /// `/services` and `/services?id=x` are the *same* go_router route, so + /// switching between them updates the existing page in place — no page + /// transition — which is what makes the slide-over panel read as an + /// overlay on the list rather than a navigation to a new screen. + static String serviceDetail(String id) => '/services?id=$id'; +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..d3add3d --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// Exact hex values from the approved design mockup. Hand-authored rather +/// than derived from [ColorScheme.fromSeed] so these values can't drift. +abstract final class AppColors { + // Light theme + static const lightBg = Color(0xFFF2F6F6); + static const lightSurface = Color(0xFFFFFFFF); + static const lightSurfaceAlt = Color(0xFFE9EFEF); + static const lightBorder = Color(0xFFDAE3E4); + static const lightText = Color(0xFF0E1B1D); + static const lightTextMuted = Color(0xFF47585B); + static const lightTextFaint = Color(0xFF7C9195); + static const lightAccent = Color(0xFF0E8E92); + static const lightAccentStrong = Color(0xFF0A7377); + static const lightAccentInk = Color(0xFFFFFFFF); + + // Dark theme + static const darkBg = Color(0xFF0C1315); + static const darkSurface = Color(0xFF121B1D); + static const darkSurfaceAlt = Color(0xFF182224); + static const darkBorder = Color(0xFF223032); + static const darkText = Color(0xFFE8F0F0); + static const darkTextMuted = Color(0xFFA9BFC2); + static const darkTextFaint = Color(0xFF6C8386); + static const darkAccent = Color(0xFF2FC6CA); + static const darkAccentStrong = Color(0xFF55D8DB); + static const darkAccentInk = Color(0xFF06282A); + + // Status — semantic, deliberately distinct from the accent hue, same + // across themes except `warning`, which is re-tuned per theme below to + // hold contrast against each surface. + static const good = Color(0xFF0CA30C); + static const warningLight = Color(0xFFC8890A); + static const warningDark = Color(0xFFE3A82B); + static const serious = Color(0xFFEC835A); + static const critical = Color(0xFFD03B3B); +} diff --git a/lib/core/theme/app_data_styles.dart b/lib/core/theme/app_data_styles.dart new file mode 100644 index 0000000..8c3f77d --- /dev/null +++ b/lib/core/theme/app_data_styles.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Typography for "technical values" (compose paths, hostnames, ids, +/// timestamps) and for tabular numerals. These are two distinct rules from +/// the design spec, not one: +/// - `dataMono*` swaps the font *family* to JetBrains Mono. +/// - `numericTabular` keeps the UI sans (Inter) but turns on the +/// `tnum` font feature so digits line up in columns (stat tiles, +/// numeric table cells) without looking like a code snippet. +@immutable +class AppDataStyles extends ThemeExtension { + const AppDataStyles({ + required this.dataMono, + required this.dataMonoSmall, + required this.numericTabular, + }); + + final TextStyle dataMono; + final TextStyle dataMonoSmall; + final TextStyle numericTabular; + + static AppDataStyles resolve({required Color textColor, required Color mutedColor}) { + return AppDataStyles( + dataMono: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 13, + color: mutedColor, + height: 1.3, + ), + dataMonoSmall: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11.5, + color: mutedColor, + height: 1.3, + ), + numericTabular: TextStyle( + fontFamily: 'Inter', + color: textColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } + + @override + AppDataStyles copyWith({ + TextStyle? dataMono, + TextStyle? dataMonoSmall, + TextStyle? numericTabular, + }) { + return AppDataStyles( + dataMono: dataMono ?? this.dataMono, + dataMonoSmall: dataMonoSmall ?? this.dataMonoSmall, + numericTabular: numericTabular ?? this.numericTabular, + ); + } + + @override + AppDataStyles lerp(ThemeExtension? other, double t) { + if (other is! AppDataStyles) return this; + return AppDataStyles( + dataMono: TextStyle.lerp(dataMono, other.dataMono, t)!, + dataMonoSmall: TextStyle.lerp(dataMonoSmall, other.dataMonoSmall, t)!, + numericTabular: TextStyle.lerp(numericTabular, other.numericTabular, t)!, + ); + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..60f2709 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; +import 'app_data_styles.dart'; +import 'status_colors.dart'; + +/// Builds the app's light/dark [ThemeData]. Colors are constructed +/// explicitly from [AppColors] rather than via [ColorScheme.fromSeed] so the +/// approved mockup's exact hex values can't drift through algorithmic tone +/// generation. +abstract final class AppTheme { + static ThemeData light() => _build(brightness: Brightness.light); + static ThemeData dark() => _build(brightness: Brightness.dark); + + static ThemeData _build({required Brightness brightness}) { + final isDark = brightness == Brightness.dark; + + final colorScheme = isDark + ? const ColorScheme.dark( + surface: AppColors.darkBg, + onSurface: AppColors.darkText, + surfaceContainerHighest: AppColors.darkSurfaceAlt, + primary: AppColors.darkAccent, + onPrimary: AppColors.darkAccentInk, + secondary: AppColors.darkAccentStrong, + onSecondary: AppColors.darkAccentInk, + error: AppColors.critical, + onError: Colors.white, + outline: AppColors.darkBorder, + outlineVariant: AppColors.darkBorder, + ) + : const ColorScheme.light( + surface: AppColors.lightBg, + onSurface: AppColors.lightText, + surfaceContainerHighest: AppColors.lightSurfaceAlt, + primary: AppColors.lightAccent, + onPrimary: AppColors.lightAccentInk, + secondary: AppColors.lightAccentStrong, + onSecondary: AppColors.lightAccentInk, + error: AppColors.critical, + onError: Colors.white, + outline: AppColors.lightBorder, + outlineVariant: AppColors.lightBorder, + ); + + final surfaceCard = isDark ? AppColors.darkSurface : AppColors.lightSurface; + final textColor = isDark ? AppColors.darkText : AppColors.lightText; + final textMuted = isDark ? AppColors.darkTextMuted : AppColors.lightTextMuted; + final textFaint = isDark ? AppColors.darkTextFaint : AppColors.lightTextFaint; + final border = isDark ? AppColors.darkBorder : AppColors.lightBorder; + + final textTheme = TextTheme( + displaySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 30, color: textColor, letterSpacing: -0.2), + headlineSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 22, color: textColor, letterSpacing: -0.1), + titleLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 17, color: textColor, letterSpacing: -0.1), + titleMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 14, color: textColor), + titleSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.6), + bodyLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 15, color: textColor, height: 1.5), + bodyMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w400, fontSize: 13.5, color: textColor, height: 1.45), + bodySmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w500, fontSize: 12, color: textMuted), + labelLarge: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13, color: textColor), + labelMedium: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 12, color: textMuted), + labelSmall: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5), + ); + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: colorScheme, + scaffoldBackgroundColor: colorScheme.surface, + canvasColor: colorScheme.surface, + dividerColor: border, + fontFamily: 'Inter', + textTheme: textTheme, + cardTheme: CardThemeData( + color: surfaceCard, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: border), + ), + ), + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: textColor, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: isDark ? AppColors.darkSurfaceAlt : AppColors.lightSurfaceAlt, + indicatorColor: colorScheme.primary.withValues(alpha: 0.16), + selectedIconTheme: IconThemeData(color: colorScheme.secondary), + unselectedIconTheme: IconThemeData(color: textMuted), + selectedLabelTextStyle: TextStyle(color: colorScheme.secondary, fontWeight: FontWeight.w700, fontSize: 12.5), + unselectedLabelTextStyle: TextStyle(color: textMuted, fontWeight: FontWeight.w500, fontSize: 12.5), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: textColor, + side: BorderSide(color: border), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + textStyle: const TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600, fontSize: 13), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: BorderSide(color: colorScheme.primary, width: 1.5), + ), + labelStyle: TextStyle(color: textMuted, fontSize: 12.5, fontWeight: FontWeight.w600), + ), + dataTableTheme: DataTableThemeData( + headingTextStyle: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w700, fontSize: 11, color: textFaint, letterSpacing: 0.5), + dataTextStyle: TextStyle(fontFamily: 'Inter', fontSize: 13, color: textColor), + dividerThickness: 1, + ), + extensions: [ + isDark ? StatusColors.dark : StatusColors.light, + AppDataStyles.resolve(textColor: textColor, mutedColor: textMuted), + ], + ); + } +} diff --git a/lib/core/theme/spacing.dart b/lib/core/theme/spacing.dart new file mode 100644 index 0000000..86d58fb --- /dev/null +++ b/lib/core/theme/spacing.dart @@ -0,0 +1,16 @@ +/// Spacing scale used across the app. Plain constants rather than a +/// [ThemeExtension] — spacing doesn't change between light/dark, so there's +/// no lerp behavior to gain from routing it through the theme mechanism. +abstract final class Spacing { + static const xs = 4.0; + static const sm = 8.0; + static const md = 12.0; + static const lg = 16.0; + static const xl = 20.0; + static const xxl = 28.0; + static const xxxl = 40.0; + + static const radiusSm = 6.0; + static const radiusMd = 8.0; + static const radiusLg = 10.0; +} diff --git a/lib/core/theme/status_colors.dart b/lib/core/theme/status_colors.dart new file mode 100644 index 0000000..9e7f9c9 --- /dev/null +++ b/lib/core/theme/status_colors.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +/// Semantic status colors (good/warning/serious/critical) used for service +/// up/down/unknown pills, backup/update success/fail pills, and stat-tile +/// severity stripes. Kept out of [ColorScheme] because Material only has a +/// single `error` role and these four are a distinct, reserved set that +/// must never be reused for anything else (e.g. a 4th categorical series). +@immutable +class StatusColors extends ThemeExtension { + const StatusColors({ + required this.good, + required this.warning, + required this.serious, + required this.critical, + }); + + final Color good; + final Color warning; + final Color serious; + final Color critical; + + static const light = StatusColors( + good: AppColors.good, + warning: AppColors.warningLight, + serious: AppColors.serious, + critical: AppColors.critical, + ); + + static const dark = StatusColors( + good: AppColors.good, + warning: AppColors.warningDark, + serious: AppColors.serious, + critical: AppColors.critical, + ); + + @override + StatusColors copyWith({ + Color? good, + Color? warning, + Color? serious, + Color? critical, + }) { + return StatusColors( + good: good ?? this.good, + warning: warning ?? this.warning, + serious: serious ?? this.serious, + critical: critical ?? this.critical, + ); + } + + @override + StatusColors lerp(ThemeExtension? other, double t) { + if (other is! StatusColors) return this; + return StatusColors( + good: Color.lerp(good, other.good, t)!, + warning: Color.lerp(warning, other.warning, t)!, + serious: Color.lerp(serious, other.serious, t)!, + critical: Color.lerp(critical, other.critical, t)!, + ); + } +} + +/// The four states a service / backup execution / update record / remote +/// node can render as a [StatusPill]. Kept as one enum shared across +/// features rather than per-feature status strings, so pill color mapping +/// lives in exactly one place. +enum Severity { good, warning, serious, critical, neutral } + +extension SeverityColor on Severity { + /// Resolves to a status color, or [muted] for [Severity.neutral] (which + /// has no reserved status hue — callers pass their theme's muted/outline + /// color for the "no data" / "not configured" case). + Color resolve(StatusColors colors, {required Color muted}) => switch (this) { + Severity.good => colors.good, + Severity.warning => colors.warning, + Severity.serious => colors.serious, + Severity.critical => colors.critical, + Severity.neutral => muted, + }; +} diff --git a/lib/core/theme/theme_mode_provider.dart b/lib/core/theme/theme_mode_provider.dart new file mode 100644 index 0000000..8029ed4 --- /dev/null +++ b/lib/core/theme/theme_mode_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../utils/shared_preferences_provider.dart'; + +part 'theme_mode_provider.g.dart'; + +const _themeModeKey = 'theme_mode'; + +@Riverpod(keepAlive: true) +class ThemeModeController extends _$ThemeModeController { + @override + ThemeMode build() { + final stored = ref.watch(sharedPreferencesProvider).getString(_themeModeKey); + return switch (stored) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + } + + Future setThemeMode(ThemeMode mode) async { + state = mode; + await ref.read(sharedPreferencesProvider).setString(_themeModeKey, mode.name); + } +} diff --git a/lib/core/theme/theme_mode_provider.g.dart b/lib/core/theme/theme_mode_provider.g.dart new file mode 100644 index 0000000..0f47de9 --- /dev/null +++ b/lib/core/theme/theme_mode_provider.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_mode_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ThemeModeController) +const themeModeControllerProvider = ThemeModeControllerProvider._(); + +final class ThemeModeControllerProvider + extends $NotifierProvider { + const ThemeModeControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'themeModeControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$themeModeControllerHash(); + + @$internal + @override + ThemeModeController create() => ThemeModeController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ThemeMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$themeModeControllerHash() => + r'521a0916c167062954c4b944c327fbeca64c294c'; + +abstract class _$ThemeModeController extends $Notifier { + ThemeMode build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ThemeMode, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/core/theme/theme_x.dart b/lib/core/theme/theme_x.dart new file mode 100644 index 0000000..5f78de5 --- /dev/null +++ b/lib/core/theme/theme_x.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'app_data_styles.dart'; +import 'status_colors.dart'; + +/// Shorthand accessors so screens read `context.status.good` / +/// `context.dataStyles.dataMono` instead of the verbose +/// `Theme.of(context).extension<...>()!` at every call site. +extension ThemeX on BuildContext { + ThemeData get theme => Theme.of(this); + ColorScheme get colors => Theme.of(this).colorScheme; + TextTheme get text => Theme.of(this).textTheme; + StatusColors get status => Theme.of(this).extension()!; + AppDataStyles get dataStyles => Theme.of(this).extension()!; +} diff --git a/lib/core/utils/relative_time.dart b/lib/core/utils/relative_time.dart new file mode 100644 index 0000000..db3c2ae --- /dev/null +++ b/lib/core/utils/relative_time.dart @@ -0,0 +1,12 @@ +/// Formats a past [DateTime] as a short relative string ("2h ago", +/// "5d ago") for activity feeds and stat-tile captions — full timestamps +/// belong in tables (as [MonoText]), not here. +String formatRelative(DateTime dateTime) { + final diff = DateTime.now().difference(dateTime); + if (diff.inSeconds < 45) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w ago'; + return '${(diff.inDays / 30).floor()}mo ago'; +} diff --git a/lib/core/utils/shared_preferences_provider.dart b/lib/core/utils/shared_preferences_provider.dart new file mode 100644 index 0000000..178a793 --- /dev/null +++ b/lib/core/utils/shared_preferences_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Overridden in `main()` with an instance obtained via +/// `SharedPreferences.getInstance()` before `runApp`. Left unimplemented by +/// default so a missing override fails loudly instead of silently using a +/// throwaway in-memory instance. +final sharedPreferencesProvider = Provider((ref) { + throw UnimplementedError( + 'sharedPreferencesProvider must be overridden in main() with a loaded SharedPreferences instance.', + ); +}); diff --git a/lib/core/widgets/backup_target_form_dialog.dart b/lib/core/widgets/backup_target_form_dialog.dart new file mode 100644 index 0000000..9189cb1 --- /dev/null +++ b/lib/core/widgets/backup_target_form_dialog.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import '../models/models.dart'; +import '../network/api_exception.dart'; + +const _methods = BackupMethod.values; + +/// Add/edit dialog for a single [BackupTarget] — shared by the node Backups +/// screen and the Services detail panel, which otherwise only differ in +/// which repository [onSave] calls. Per-target `params` editing stays out +/// of scope here, same reasoning as the rest of the app: a power-user +/// escape hatch, not worth a nested key/value editor in v1. +Future showBackupTargetFormDialog( + BuildContext context, { + BackupTarget? existing, + required Future Function(BackupTarget draft) onSave, +}) { + return showDialog( + context: context, + builder: (context) => _BackupTargetFormDialog(existing: existing, onSave: onSave), + ); +} + +class _BackupTargetFormDialog extends StatefulWidget { + const _BackupTargetFormDialog({this.existing, required this.onSave}); + + final BackupTarget? existing; + final Future Function(BackupTarget draft) onSave; + + @override + State<_BackupTargetFormDialog> createState() => _BackupTargetFormDialogState(); +} + +class _BackupTargetFormDialogState extends State<_BackupTargetFormDialog> { + final _formKey = GlobalKey(); + late final _nameController = TextEditingController(text: widget.existing?.name); + late final _remoteController = TextEditingController(text: widget.existing?.remote); + late final _scheduleController = TextEditingController(text: widget.existing?.schedule); + late BackupMethod _method = widget.existing?.method ?? BackupMethod.restic; + bool _saving = false; + String? _error; + + @override + void dispose() { + _nameController.dispose(); + _remoteController.dispose(); + _scheduleController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _saving = true; + _error = null; + }); + + final schedule = _scheduleController.text.trim(); + final draft = (widget.existing ?? const BackupTarget(name: '', method: BackupMethod.restic, remote: '')).copyWith( + name: _nameController.text.trim(), + method: _method, + remote: _remoteController.text.trim(), + schedule: schedule.isEmpty ? null : schedule, + ); + + try { + await widget.onSave(draft); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = e is ApiException ? e.userMessage : e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.existing == null ? 'Add backup target' : 'Edit backup target'), + content: Form( + key: _formKey, + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + ), + const SizedBox(height: 14), + DropdownButtonFormField( + initialValue: _method, + decoration: const InputDecoration(labelText: 'Method'), + items: [for (final m in _methods) DropdownMenuItem(value: m, child: Text(m.name))], + onChanged: (v) => setState(() => _method = v ?? _method), + ), + const SizedBox(height: 14), + TextFormField( + controller: _remoteController, + decoration: InputDecoration( + labelText: 'Remote', + hintText: switch (_method) { + BackupMethod.restic => '/mnt/backup/repo or s3:s3.amazonaws.com/bucket/path or sftp:user@host:/path', + BackupMethod.rsync => 'user@host:/path', + BackupMethod.external => 'informational only — backup is managed outside the API', + }, + helperText: _method == BackupMethod.restic + ? 'Restic repository — encrypted with this node\'s restic password (Settings).' + : null, + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _scheduleController, + decoration: const InputDecoration( + labelText: 'Schedule (optional)', + hintText: '0 2 * * * — standard 5-field cron; empty = manual only', + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/core/widgets/backup_target_row.dart b/lib/core/widgets/backup_target_row.dart new file mode 100644 index 0000000..fe19250 --- /dev/null +++ b/lib/core/widgets/backup_target_row.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/models.dart'; +import '../network/api_exception.dart'; +import '../theme/theme_x.dart'; +import 'mono_text.dart'; + +/// One backup target: identity + schedule/last-run caption, and either +/// Run/Edit/Delete actions or — while a run it triggered is in flight — a +/// live progress bar polled via [fetchProgress]. Polling is a plain local +/// [Timer] scoped to this row's lifetime, not a Riverpod provider: progress +/// only matters while this exact row is on screen and something is +/// actively running, so there's nothing to share or keep alive beyond that. +class BackupTargetRow extends StatefulWidget { + const BackupTargetRow({ + super.key, + required this.target, + required this.onRun, + required this.fetchProgress, + required this.onSettled, + required this.onEdit, + required this.onDelete, + }); + + final BackupTarget target; + final Future Function() onRun; + final Future Function() fetchProgress; + final void Function(RunState finalState) onSettled; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + State createState() => _BackupTargetRowState(); +} + +class _BackupTargetRowState extends State { + Timer? _timer; + RunState? _runState; + bool _starting = false; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _handleRun() async { + setState(() => _starting = true); + try { + await widget.onRun(); + setState(() { + _starting = false; + _runState = const RunState(running: true); + }); + _poll(); + _timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll()); + } catch (e) { + if (!mounted) return; + setState(() => _starting = false); + final message = e is ApiException ? e.userMessage : e.toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + + Future _poll() async { + final state = await widget.fetchProgress(); + if (!mounted) return; + setState(() => _runState = state); + if (!state.running) { + _timer?.cancel(); + _timer = null; + widget.onSettled(state); + } + } + + @override + Widget build(BuildContext context) { + final target = widget.target; + final running = _runState?.running ?? false; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text(target.name, style: context.text.labelLarge), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text(target.method.name, style: context.dataStyles.dataMonoSmall), + ), + ], + ), + const SizedBox(height: 3), + MonoText(target.remote, small: true), + const SizedBox(height: 3), + Text(_caption(target), style: context.text.bodySmall), + ], + ), + ), + const SizedBox(width: 8), + if (running) + const SizedBox.shrink() + else ...[ + _starting + ? const Padding( + padding: EdgeInsets.all(8), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ) + : IconButton( + tooltip: 'Run now', + icon: const Icon(Icons.play_arrow_rounded, size: 20), + onPressed: _handleRun, + ), + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: widget.onEdit, + ), + IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: widget.onDelete, + ), + ], + ], + ), + if (running) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (_runState!.percent > 0 && _runState!.percent <= 100) + ? _runState!.percent / 100 + : null, + minHeight: 5, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '${_runState!.percent.toStringAsFixed(0)}%', + style: context.dataStyles.numericTabular.copyWith(fontSize: 12), + ), + if (_runState!.eta != null) ...[ + const SizedBox(width: 8), + Text('ETA ${_runState!.eta}', style: context.text.bodySmall), + ], + if (_runState!.message?.isNotEmpty == true) ...[ + const SizedBox(width: 8), + Expanded( + child: MonoText(_runState!.message!, small: true, maxLines: 1), + ), + ], + ], + ), + ], + ], + ), + ); + } + + String _caption(BackupTarget target) { + final dateFormat = DateFormat('MMM d, HH:mm'); + final parts = [ + target.schedule?.isNotEmpty == true ? 'Schedule: ${target.schedule}' : 'Manual only', + if (target.lastRun != null) 'last ${dateFormat.format(target.lastRun!.toLocal())}', + if (target.nextRun != null) 'next ${dateFormat.format(target.nextRun!.toLocal())}', + ]; + return parts.join(' · '); + } +} diff --git a/lib/core/widgets/backup_targets_list.dart b/lib/core/widgets/backup_targets_list.dart new file mode 100644 index 0000000..86c5561 --- /dev/null +++ b/lib/core/widgets/backup_targets_list.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../models/models.dart'; +import '../theme/theme_x.dart'; +import 'backup_target_row.dart'; + +/// A list of [BackupTargetRow]s plus an "Add target" action — shared by the +/// node Backups screen and the Services detail panel. +class BackupTargetsList extends StatelessWidget { + const BackupTargetsList({ + super.key, + required this.targets, + required this.onAdd, + required this.onEdit, + required this.onDelete, + required this.onRun, + required this.fetchProgress, + required this.onSettled, + }); + + final List targets; + final VoidCallback onAdd; + final void Function(BackupTarget target) onEdit; + final void Function(BackupTarget target) onDelete; + final Future Function(BackupTarget target) onRun; + final Future Function(BackupTarget target) fetchProgress; + final void Function(BackupTarget target, RunState finalState) onSettled; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (targets.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('No backup targets configured.', style: context.text.bodySmall), + ) + else + for (var i = 0; i < targets.length; i++) ...[ + if (i > 0) const Divider(height: 1), + BackupTargetRow( + key: ValueKey(targets[i].id), + target: targets[i], + onRun: () => onRun(targets[i]), + fetchProgress: () => fetchProgress(targets[i]), + onSettled: (state) => onSettled(targets[i], state), + onEdit: () => onEdit(targets[i]), + onDelete: () => onDelete(targets[i]), + ), + ], + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add target'), + ), + ], + ); + } +} diff --git a/lib/core/widgets/data_table_scaffold.dart b/lib/core/widgets/data_table_scaffold.dart new file mode 100644 index 0000000..1dd64c0 --- /dev/null +++ b/lib/core/widgets/data_table_scaffold.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// The bordered, horizontally-scrollable card wrapper used by every dense +/// table in the app (Services, Fleet, Updates, Backup history). A thin +/// shell around [DataTable] — column/row content stays screen-specific, +/// this just guarantees the same card chrome, spacing, and empty-state +/// fallback everywhere. +class DataTableScaffold extends StatelessWidget { + const DataTableScaffold({ + super.key, + required this.columns, + required this.rows, + this.empty, + this.columnSpacing = 28, + this.horizontalMargin = 16, + }); + + final List columns; + final List rows; + final Widget? empty; + final double columnSpacing; + final double horizontalMargin; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: context.colors.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: rows.isEmpty && empty != null + ? empty + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: MediaQuery.sizeOf(context).width), + child: DataTable( + showCheckboxColumn: false, + columns: columns, + rows: rows, + columnSpacing: columnSpacing, + horizontalMargin: horizontalMargin, + headingRowHeight: 38, + dataRowMinHeight: 46, + dataRowMaxHeight: 58, + dividerThickness: 1, + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..6b404c2 --- /dev/null +++ b/lib/core/widgets/empty_state.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// Centered "nothing here yet" placeholder — no services discovered, no +/// connections saved, no update history. An optional action gives the user +/// the next step rather than a dead end. +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.message, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String? message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 30, color: context.colors.outline), + const SizedBox(height: 12), + Text(title, style: context.text.titleMedium, textAlign: TextAlign.center), + if (message != null) ...[ + const SizedBox(height: 4), + Text( + message!, + style: context.text.bodySmall, + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 16), + OutlinedButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ); + } +} diff --git a/lib/core/widgets/error_banner.dart b/lib/core/widgets/error_banner.dart new file mode 100644 index 0000000..b054b43 --- /dev/null +++ b/lib/core/widgets/error_banner.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// Inline error state for a failed fetch — an `AsyncValue.error` case in a +/// screen body. Deliberately takes a plain [message] string rather than an +/// `ApiException` so `core/widgets` has no dependency on `core/network`; +/// callers resolve `exception.userMessage` before passing it in. +class ErrorBanner extends StatelessWidget { + const ErrorBanner({super.key, required this.message, this.onRetry}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: context.status.critical.withValues(alpha: 0.4)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: 18, color: context.status.critical), + const SizedBox(width: 12), + Expanded(child: Text(message, style: context.text.bodyMedium)), + if (onRetry != null) ...[ + const SizedBox(width: 12), + TextButton(onPressed: onRetry, child: const Text('Retry')), + ], + ], + ), + ); + } +} diff --git a/lib/core/widgets/mono_text.dart b/lib/core/widgets/mono_text.dart new file mode 100644 index 0000000..fdb56ca --- /dev/null +++ b/lib/core/widgets/mono_text.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// A technical value — compose file path, hostname, id, URL — rendered in +/// the monospace data face so it reads as "an exact string", distinct from +/// prose. Use for anything a user might copy-paste or match against a log. +class MonoText extends StatelessWidget { + const MonoText( + this.value, { + super.key, + this.small = false, + this.color, + this.maxLines = 1, + this.overflow = TextOverflow.ellipsis, + }); + + final String value; + final bool small; + final Color? color; + final int? maxLines; + final TextOverflow overflow; + + @override + Widget build(BuildContext context) { + final base = small ? context.dataStyles.dataMonoSmall : context.dataStyles.dataMono; + return Text( + value, + maxLines: maxLines, + overflow: overflow, + style: color == null ? base : base.copyWith(color: color), + ); + } +} diff --git a/lib/core/widgets/nav_rail_shell.dart b/lib/core/widgets/nav_rail_shell.dart new file mode 100644 index 0000000..de362d3 --- /dev/null +++ b/lib/core/widgets/nav_rail_shell.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../connections/connection_providers.dart'; +import '../routing/route_paths.dart'; +import '../theme/theme_mode_provider.dart'; +import '../theme/theme_x.dart'; + +class _NavItem { + const _NavItem(this.path, this.label, this.icon, this.selectedIcon); + final String path; + final String label; + final IconData icon; + final IconData selectedIcon; +} + +const _navItems = [ + _NavItem(RoutePaths.dashboard, 'Dashboard', Icons.grid_view_outlined, Icons.grid_view_rounded), + _NavItem(RoutePaths.services, 'Services', Icons.layers_outlined, Icons.layers_rounded), + _NavItem(RoutePaths.backups, 'Backups', Icons.cloud_outlined, Icons.cloud_rounded), + _NavItem(RoutePaths.fleet, 'Fleet', Icons.hub_outlined, Icons.hub_rounded), + _NavItem(RoutePaths.updates, 'Updates', Icons.history_outlined, Icons.history_rounded), + _NavItem(RoutePaths.settings, 'Settings', Icons.tune_outlined, Icons.tune_rounded), +]; + +/// The persistent left nav rail + top bar frame wrapping every screen, per +/// the approved mockup. Wraps go_router's `ShellRoute` child. +class NavRailShell extends ConsumerWidget { + const NavRailShell({super.key, required this.currentPath, required this.child}); + + final String currentPath; + final Widget child; + + int get _selectedIndex { + final index = _navItems.indexWhere((i) => currentPath.startsWith(i.path)); + return index == -1 ? 0 : index; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final narrow = MediaQuery.sizeOf(context).width < 860; + + return Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Rail(selectedIndex: _selectedIndex, narrow: narrow), + const VerticalDivider(width: 1, thickness: 1), + Expanded( + child: Column( + children: [ + _TopBar(currentPath: currentPath), + const Divider(height: 1, thickness: 1), + Expanded(child: child), + ], + ), + ), + ], + ), + ); + } +} + +class _Rail extends ConsumerWidget { + const _Rail({required this.selectedIndex, required this.narrow}); + + final int selectedIndex; + final bool narrow; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeConnection = ref.watch(activeConnectionProvider); + + return SizedBox( + width: narrow ? 72 : 226, + child: Container( + color: context.theme.navigationRailTheme.backgroundColor, + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 18, 14, 20), + child: Row( + mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: context.colors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.hub_rounded, size: 16, color: context.colors.onPrimary), + ), + if (!narrow) ...[ + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('NodeMaster', style: context.text.titleMedium?.copyWith(fontSize: 14.5)), + Text('Manager', style: context.text.bodySmall), + ], + ), + ), + ], + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + for (var i = 0; i < _navItems.length; i++) + _RailButton(item: _navItems[i], selected: i == selectedIndex, narrow: narrow), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: _ConnectionChip(name: activeConnection?.name, url: activeConnection?.baseUrl, narrow: narrow), + ), + ], + ), + ), + ), + ); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({required this.item, required this.selected, required this.narrow}); + + final _NavItem item; + final bool selected; + final bool narrow; + + @override + Widget build(BuildContext context) { + final fg = selected ? context.colors.secondary : context.text.bodyMedium?.color; + final bg = selected ? context.colors.primary.withValues(alpha: 0.14) : Colors.transparent; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Material( + color: bg, + borderRadius: BorderRadius.circular(7), + child: InkWell( + borderRadius: BorderRadius.circular(7), + onTap: () => context.go(item.path), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: narrow ? 0 : 10, vertical: 9), + child: Row( + mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Icon(selected ? item.selectedIcon : item.icon, size: 18, color: fg), + if (!narrow) ...[ + const SizedBox(width: 10), + Text( + item.label, + style: context.text.bodyMedium?.copyWith( + color: fg, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _ConnectionChip extends StatelessWidget { + const _ConnectionChip({required this.name, required this.url, required this.narrow}); + + final String? name; + final String? url; + final bool narrow; + + @override + Widget build(BuildContext context) { + final dot = Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: name == null ? context.colors.outline : context.status.good, + shape: BoxShape.circle, + ), + ); + if (narrow) return Center(child: dot); + + return Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + children: [ + dot, + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name ?? 'No connection', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.labelLarge?.copyWith(fontSize: 12), + ), + if (url != null) + Text( + url!.replaceFirst(RegExp(r'^https?://'), ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.dataStyles.dataMonoSmall, + ), + ], + ), + ), + ], + ), + ); + } +} + +const _titles = { + RoutePaths.dashboard: ('Dashboard', _connSubtitle), + RoutePaths.services: ('Services', _connSubtitle), + RoutePaths.backups: ('Backups', _connSubtitle), + RoutePaths.fleet: ('Fleet', _connSubtitle), + RoutePaths.updates: ('Updates', _connSubtitle), + RoutePaths.settings: ('Settings', _connSubtitle), +}; + +String _connSubtitle(WidgetRef ref) { + final connection = ref.watch(activeConnectionProvider); + return connection == null ? 'No connection configured' : 'Connected to ${connection.name}'; +} + +class _TopBar extends ConsumerWidget { + const _TopBar({required this.currentPath}); + + final String currentPath; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entry = _titles.entries.firstWhere( + (e) => currentPath.startsWith(e.key), + orElse: () => _titles.entries.first, + ); + final title = entry.value.$1; + final subtitle = entry.value.$2(ref); + + final mode = ref.watch(themeModeControllerProvider); + final effectiveDark = switch (mode) { + ThemeMode.dark => true, + ThemeMode.light => false, + ThemeMode.system => MediaQuery.platformBrightnessOf(context) == Brightness.dark, + }; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: context.text.titleLarge), + Text(subtitle, style: context.text.bodySmall), + ], + ), + ), + IconButton( + tooltip: effectiveDark ? 'Switch to light theme' : 'Switch to dark theme', + icon: Icon(effectiveDark ? Icons.dark_mode_outlined : Icons.light_mode_outlined), + onPressed: () => ref + .read(themeModeControllerProvider.notifier) + .setThemeMode(effectiveDark ? ThemeMode.light : ThemeMode.dark), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/slide_over_panel.dart b/lib/core/widgets/slide_over_panel.dart new file mode 100644 index 0000000..c8e4dfe --- /dev/null +++ b/lib/core/widgets/slide_over_panel.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// A right-anchored detail panel over the still-visible list behind it — +/// the "inspect a record without losing list context" pattern used by +/// Services (and reused by Fleet's per-node cards). Place as the last child +/// of a [Stack] alongside the screen's main content; it fills the stack +/// itself via [Positioned.fill] and no-ops when [open] is false. +class SlideOverPanel extends StatelessWidget { + const SlideOverPanel({ + super.key, + required this.open, + required this.onClose, + required this.title, + this.subtitle, + required this.body, + this.actions = const [], + this.headerActions = const [], + this.width = 420, + }); + + final bool open; + final VoidCallback onClose; + final String title; + final Widget? subtitle; + final Widget body; + final List actions; + + /// Icon buttons shown before the close button — for actions that belong + /// with the record identity (e.g. Edit) rather than the primary action + /// row at the bottom, which stays reserved for the few actions that need + /// full label width. + final List headerActions; + final double width; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final duration = reduceMotion ? Duration.zero : const Duration(milliseconds: 200); + + return Positioned.fill( + child: IgnorePointer( + ignoring: !open, + child: Stack( + children: [ + AnimatedOpacity( + opacity: open ? 1 : 0, + duration: duration, + child: GestureDetector( + onTap: onClose, + child: Container(color: Colors.black.withValues(alpha: 0.35)), + ), + ), + Align( + alignment: Alignment.centerRight, + child: ClipRect( + child: AnimatedSlide( + offset: open ? Offset.zero : const Offset(1, 0), + duration: duration, + curve: Curves.easeOutCubic, + child: Container( + width: width, + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.92), + height: double.infinity, + decoration: BoxDecoration( + color: context.colors.surface, + border: Border(left: BorderSide(color: context.colors.outlineVariant)), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.18), blurRadius: 48, offset: const Offset(-8, 0)), + ], + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: context.text.titleLarge), + if (subtitle != null) ...[ + const SizedBox(height: 6), + subtitle!, + ], + ], + ), + ), + ...headerActions, + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, size: 19), + tooltip: 'Close', + ), + ], + ), + ), + const Divider(height: 1), + Expanded(child: body), + if (actions.isNotEmpty) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: actions[i]), + ], + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/sparkline.dart b/lib/core/widgets/sparkline.dart new file mode 100644 index 0000000..476cb05 --- /dev/null +++ b/lib/core/widgets/sparkline.dart @@ -0,0 +1,106 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/theme_x.dart'; + +/// A single-series trend line — currently just backup-duration history. +/// Hand-rolled `CustomPainter` rather than a charting dependency: one chart +/// in the whole app, a specific visual spec (faint grid, thin line, +/// emphasized endpoint), full control for near-zero cost. +class Sparkline extends StatelessWidget { + const Sparkline({super.key, required this.values, this.width = 240, this.height = 52}); + + final List values; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: CustomPaint( + painter: _SparklinePainter( + values: values, + lineColor: context.colors.primary, + gridColor: context.colors.outlineVariant, + ), + ), + ); + } +} + +class _SparklinePainter extends CustomPainter { + _SparklinePainter({required this.values, required this.lineColor, required this.gridColor}); + + final List values; + final Color lineColor; + final Color gridColor; + + static const _topPad = 6.0; + static const _bottomPad = 6.0; + + @override + void paint(Canvas canvas, Size size) { + if (values.isEmpty) return; + + final minV = values.reduce(math.min); + final maxV = values.reduce(math.max); + final range = (maxV - minV).abs() < 1e-9 ? 1.0 : maxV - minV; + final plotHeight = size.height - _topPad - _bottomPad; + + final gridPaint = Paint() + ..color = gridColor + ..strokeWidth = 1; + canvas.drawLine(Offset(0, _topPad), Offset(size.width, _topPad), gridPaint); + canvas.drawLine( + Offset(0, size.height - _bottomPad), + Offset(size.width, size.height - _bottomPad), + gridPaint, + ); + + if (values.length == 1) { + final y = _topPad + plotHeight / 2; + canvas.drawCircle(Offset(size.width, y), 3.4, Paint()..color = lineColor); + return; + } + + final dx = size.width / (values.length - 1); + final points = [ + for (var i = 0; i < values.length; i++) + Offset(i * dx, _topPad + plotHeight - ((values[i] - minV) / range) * plotHeight), + ]; + + final fillPath = Path()..moveTo(points.first.dx, size.height - _bottomPad); + for (final p in points) { + fillPath.lineTo(p.dx, p.dy); + } + fillPath + ..lineTo(points.last.dx, size.height - _bottomPad) + ..close(); + canvas.drawPath(fillPath, Paint()..color = lineColor.withValues(alpha: 0.14)); + + final linePath = Path()..moveTo(points.first.dx, points.first.dy); + for (final p in points.skip(1)) { + linePath.lineTo(p.dx, p.dy); + } + canvas.drawPath( + linePath, + Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + canvas.drawCircle(points.last, 3.4, Paint()..color = lineColor); + } + + @override + bool shouldRepaint(covariant _SparklinePainter oldDelegate) => + oldDelegate.values != values || + oldDelegate.lineColor != lineColor || + oldDelegate.gridColor != gridColor; +} diff --git a/lib/core/widgets/stat_tile.dart b/lib/core/widgets/stat_tile.dart new file mode 100644 index 0000000..6352141 --- /dev/null +++ b/lib/core/widgets/stat_tile.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../theme/status_colors.dart'; +import '../theme/theme_x.dart'; + +/// A dashboard summary tile: an uppercase label, a large tabular-nums value +/// (with an optional muted trailing qualifier, e.g. "7 / 9"), a caption, and +/// a left severity stripe communicating worst-case state at a glance — +/// severity is a first-class visual channel here, not just the number. +class StatTile extends StatelessWidget { + const StatTile({ + super.key, + required this.label, + required this.value, + this.valueQualifier, + this.caption, + this.severity = Severity.good, + }); + + final String label; + final String value; + final String? valueQualifier; + final String? caption; + final Severity severity; + + @override + Widget build(BuildContext context) { + final stripeColor = severity.resolve(context.status, muted: context.colors.outline); + + return Container( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: context.colors.outlineVariant), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 2, offset: const Offset(0, 1)), + ], + ), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + Container(width: 3, color: stripeColor), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(13, 14, 13, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: context.text.titleSmall), + const SizedBox(height: 8), + Text.rich( + TextSpan( + style: context.dataStyles.numericTabular.copyWith( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + children: [ + TextSpan(text: value), + if (valueQualifier != null) + TextSpan( + text: ' $valueQualifier', + style: context.text.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ), + if (caption != null) ...[ + const SizedBox(height: 4), + Text( + caption!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.bodySmall, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/status_pill.dart b/lib/core/widgets/status_pill.dart new file mode 100644 index 0000000..e3b1b56 --- /dev/null +++ b/lib/core/widgets/status_pill.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../theme/status_colors.dart'; +import '../theme/theme_x.dart'; + +/// A dot + label badge encoding state — service up/down/unknown, backup or +/// update success/fail, node reachable/unreachable. The same component +/// everywhere a status appears so a given [Severity] always reads the same +/// color, matching the design rule that status color is reserved and never +/// doubles as the brand accent. +class StatusPill extends StatelessWidget { + const StatusPill({super.key, required this.label, required this.severity}); + + final String label; + final Severity severity; + + @override + Widget build(BuildContext context) { + final color = severity.resolve(context.status, muted: context.colors.outline); + + return Container( + padding: const EdgeInsets.fromLTRB(7, 3, 9, 3), + decoration: BoxDecoration( + // 8% tint, not 14% — measured against the dark surface, a same-hue + // tint background caps critical's text contrast around ~3.5:1 no + // matter the blend ratio (text and background converge toward the + // same hue as alpha rises, and approach the plain-surface ceiling + // as it falls); 8% is close to that ceiling while keeping + // good/warning/serious comfortably at 4.5:1+. Below AA-for-text on + // its own, mitigated the same way the design system's status + // palette always mitigates this: label text, never color alone. + color: severity == Severity.neutral + ? context.colors.surfaceContainerHighest + : Color.alphaBlend(color.withValues(alpha: 0.08), context.colors.surface), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: context.text.labelMedium?.copyWith( + color: severity == Severity.neutral ? context.colors.onSurfaceVariant : color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/data/repositories/node_repository.dart b/lib/data/repositories/node_repository.dart new file mode 100644 index 0000000..24034ea --- /dev/null +++ b/lib/data/repositories/node_repository.dart @@ -0,0 +1,79 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_client_provider.dart'; +import '../../core/network/node_master_api_client.dart'; + +part 'node_repository.g.dart'; + +/// Wraps the `/node/*` endpoints — this host's own identity, backup config, +/// scan trigger, and update log. +class NodeRepository { + const NodeRepository(this._client); + + final NodeMasterApiClient _client; + + Future getNode() => _client.getNode(); + Future updateNode(NodeInfo node) => _client.updateNode(node); + + Future getBackup() => _client.getNodeBackup(); + Future updateBackup(BackupConfig config) => _client.updateNodeBackup(config); + Future runBackup() => _client.runNodeBackup(); + + Future> getBackupTargets() => _client.getNodeBackupTargets(); + Future addBackupTarget(BackupTarget target) => _client.addNodeBackupTarget(target); + Future updateBackupTarget(String targetId, BackupTarget target) => + _client.updateNodeBackupTarget(targetId, target); + Future deleteBackupTarget(String targetId) => _client.deleteNodeBackupTarget(targetId); + Future runBackupTarget(String targetId) => _client.runNodeBackupTarget(targetId); + Future getBackupTargetProgress(String targetId) => + _client.getNodeBackupTargetProgress(targetId); + Future> getBackupProgress() => _client.getNodeBackupProgress(); + + Future scan({String? folder}) => _client.scan(folder: folder); + + Future> getUpdates() => _client.getUpdates(); + Future addUpdate(UpdateRecord record) => _client.addUpdate(record); + Future deleteUpdate(String id) => _client.deleteUpdate(id); +} + +/// Null when no connection is active — mirrors [apiClientProvider] so every +/// downstream provider that watches this gets the same "no connection" +/// signal without re-deriving it from the connection state itself. +@riverpod +NodeRepository? nodeRepository(Ref ref) { + final client = ref.watch(apiClientProvider); + if (client == null) return null; + return NodeRepository(client); +} + +/// The active connection's node info, or null if no connection is +/// configured. Exceptions from an unreachable/misbehaving node surface as +/// [AsyncError] — this is also the smoke-test path proving the client → +/// repository → provider chain works end to end against a live server. +@riverpod +Future currentNode(Ref ref) async { + final repo = ref.watch(nodeRepositoryProvider); + if (repo == null) return null; + return repo.getNode(); +} + +/// The node-level backup config — manual-refresh only, same reasoning as +/// `servicesListProvider`: this backs a detail-heavy screen with its own +/// edit dialog, where a silent background refresh could yank state out +/// from under an in-progress edit. +@riverpod +Future backupConfig(Ref ref) async { + final repo = ref.watch(nodeRepositoryProvider); + if (repo == null) return null; + return repo.getBackup(); +} + +/// The OS update log — manual-refresh only, same reasoning as the other +/// list providers in this app. +@riverpod +Future> updatesList(Ref ref) async { + final repo = ref.watch(nodeRepositoryProvider); + if (repo == null) return []; + return repo.getUpdates(); +} diff --git a/lib/data/repositories/node_repository.g.dart b/lib/data/repositories/node_repository.g.dart new file mode 100644 index 0000000..d35f7b5 --- /dev/null +++ b/lib/data/repositories/node_repository.g.dart @@ -0,0 +1,216 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'node_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Null when no connection is active — mirrors [apiClientProvider] so every +/// downstream provider that watches this gets the same "no connection" +/// signal without re-deriving it from the connection state itself. + +@ProviderFor(nodeRepository) +const nodeRepositoryProvider = NodeRepositoryProvider._(); + +/// Null when no connection is active — mirrors [apiClientProvider] so every +/// downstream provider that watches this gets the same "no connection" +/// signal without re-deriving it from the connection state itself. + +final class NodeRepositoryProvider + extends + $FunctionalProvider + with $Provider { + /// Null when no connection is active — mirrors [apiClientProvider] so every + /// downstream provider that watches this gets the same "no connection" + /// signal without re-deriving it from the connection state itself. + const NodeRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'nodeRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$nodeRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + NodeRepository? create(Ref ref) { + return nodeRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NodeRepository? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$nodeRepositoryHash() => r'c7cf7aac4b407fd4a21a56f5c3d34c81fa9abe5e'; + +/// The active connection's node info, or null if no connection is +/// configured. Exceptions from an unreachable/misbehaving node surface as +/// [AsyncError] — this is also the smoke-test path proving the client → +/// repository → provider chain works end to end against a live server. + +@ProviderFor(currentNode) +const currentNodeProvider = CurrentNodeProvider._(); + +/// The active connection's node info, or null if no connection is +/// configured. Exceptions from an unreachable/misbehaving node surface as +/// [AsyncError] — this is also the smoke-test path proving the client → +/// repository → provider chain works end to end against a live server. + +final class CurrentNodeProvider + extends + $FunctionalProvider< + AsyncValue, + NodeInfo?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// The active connection's node info, or null if no connection is + /// configured. Exceptions from an unreachable/misbehaving node surface as + /// [AsyncError] — this is also the smoke-test path proving the client → + /// repository → provider chain works end to end against a live server. + const CurrentNodeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'currentNodeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$currentNodeHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return currentNode(ref); + } +} + +String _$currentNodeHash() => r'e1742b62ba610695d0e8a560d554902fa29d128c'; + +/// The node-level backup config — manual-refresh only, same reasoning as +/// `servicesListProvider`: this backs a detail-heavy screen with its own +/// edit dialog, where a silent background refresh could yank state out +/// from under an in-progress edit. + +@ProviderFor(backupConfig) +const backupConfigProvider = BackupConfigProvider._(); + +/// The node-level backup config — manual-refresh only, same reasoning as +/// `servicesListProvider`: this backs a detail-heavy screen with its own +/// edit dialog, where a silent background refresh could yank state out +/// from under an in-progress edit. + +final class BackupConfigProvider + extends + $FunctionalProvider< + AsyncValue, + BackupConfig?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// The node-level backup config — manual-refresh only, same reasoning as + /// `servicesListProvider`: this backs a detail-heavy screen with its own + /// edit dialog, where a silent background refresh could yank state out + /// from under an in-progress edit. + const BackupConfigProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'backupConfigProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$backupConfigHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return backupConfig(ref); + } +} + +String _$backupConfigHash() => r'd3eeedd7536dd8610b34b922bd5658c507c91efe'; + +/// The OS update log — manual-refresh only, same reasoning as the other +/// list providers in this app. + +@ProviderFor(updatesList) +const updatesListProvider = UpdatesListProvider._(); + +/// The OS update log — manual-refresh only, same reasoning as the other +/// list providers in this app. + +final class UpdatesListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// The OS update log — manual-refresh only, same reasoning as the other + /// list providers in this app. + const UpdatesListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'updatesListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$updatesListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return updatesList(ref); + } +} + +String _$updatesListHash() => r'01c6dd3b69dfd8f98e57f29e43e3fa60d014a6e4'; diff --git a/lib/data/repositories/nodes_repository.dart b/lib/data/repositories/nodes_repository.dart new file mode 100644 index 0000000..dc5b6d9 --- /dev/null +++ b/lib/data/repositories/nodes_repository.dart @@ -0,0 +1,60 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_client_provider.dart'; +import '../../core/network/node_master_api_client.dart'; +import '../../core/polling/polling_async_notifier.dart'; + +part 'nodes_repository.g.dart'; + +/// Wraps the `/nodes/*` endpoints — the registry of sibling NodeMaster +/// hosts and the server-side fan-out status view. +class NodesRepository { + const NodesRepository(this._client); + + final NodeMasterApiClient _client; + + Future> getAll() => _client.getAllRemoteNodes(); + Future get(String id) => _client.getRemoteNode(id); + Future add(RemoteNode node) => _client.addRemoteNode(node); + Future update(String id, RemoteNode node) => _client.updateRemoteNode(id, node); + Future delete(String id) => _client.deleteRemoteNode(id); + Future> getAggregated() => _client.getAggregated(); +} + +/// Null when no connection is active, mirroring [apiClientProvider]. +@riverpod +NodesRepository? nodesRepository(Ref ref) { + final client = ref.watch(apiClientProvider); + if (client == null) return null; + return NodesRepository(client); +} + +/// The registered-nodes list. Manual-refresh only, same reasoning as +/// `servicesListProvider` — this backs Fleet's registry table (M6). +@riverpod +Future> remoteNodesList(Ref ref) async { + final repo = ref.watch(nodesRepositoryProvider); + if (repo == null) return []; + return repo.getAll(); +} + +/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared +/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid +/// (M6) — one poll, cached by Riverpod, rather than each screen running +/// its own duplicate timer against the same endpoint. +@riverpod +class AggregatedNodes extends _$AggregatedNodes with PollingMixin> { + @override + Duration get interval => const Duration(seconds: 25); + + @override + Future> fetch() async { + final repo = ref.watch(nodesRepositoryProvider); + if (repo == null) return []; + return repo.getAggregated(); + } + + @override + Future> build() => startPolling(); +} diff --git a/lib/data/repositories/nodes_repository.g.dart b/lib/data/repositories/nodes_repository.g.dart new file mode 100644 index 0000000..f254a77 --- /dev/null +++ b/lib/data/repositories/nodes_repository.g.dart @@ -0,0 +1,180 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nodes_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Null when no connection is active, mirroring [apiClientProvider]. + +@ProviderFor(nodesRepository) +const nodesRepositoryProvider = NodesRepositoryProvider._(); + +/// Null when no connection is active, mirroring [apiClientProvider]. + +final class NodesRepositoryProvider + extends + $FunctionalProvider< + NodesRepository?, + NodesRepository?, + NodesRepository? + > + with $Provider { + /// Null when no connection is active, mirroring [apiClientProvider]. + const NodesRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'nodesRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$nodesRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + NodesRepository? create(Ref ref) { + return nodesRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NodesRepository? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$nodesRepositoryHash() => r'7dd03a46ea79ae87bb251ff273ff77a634a53f5f'; + +/// The registered-nodes list. Manual-refresh only, same reasoning as +/// `servicesListProvider` — this backs Fleet's registry table (M6). + +@ProviderFor(remoteNodesList) +const remoteNodesListProvider = RemoteNodesListProvider._(); + +/// The registered-nodes list. Manual-refresh only, same reasoning as +/// `servicesListProvider` — this backs Fleet's registry table (M6). + +final class RemoteNodesListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// The registered-nodes list. Manual-refresh only, same reasoning as + /// `servicesListProvider` — this backs Fleet's registry table (M6). + const RemoteNodesListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'remoteNodesListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$remoteNodesListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return remoteNodesList(ref); + } +} + +String _$remoteNodesListHash() => r'a2d3bd0b2e2f2cd54a7590bb5ace8a0c63e3d2aa'; + +/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared +/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid +/// (M6) — one poll, cached by Riverpod, rather than each screen running +/// its own duplicate timer against the same endpoint. + +@ProviderFor(AggregatedNodes) +const aggregatedNodesProvider = AggregatedNodesProvider._(); + +/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared +/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid +/// (M6) — one poll, cached by Riverpod, rather than each screen running +/// its own duplicate timer against the same endpoint. +final class AggregatedNodesProvider + extends + $AsyncNotifierProvider> { + /// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared + /// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid + /// (M6) — one poll, cached by Riverpod, rather than each screen running + /// its own duplicate timer against the same endpoint. + const AggregatedNodesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'aggregatedNodesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$aggregatedNodesHash(); + + @$internal + @override + AggregatedNodes create() => AggregatedNodes(); +} + +String _$aggregatedNodesHash() => r'14dd7a1e7d4b9caf2d7c975613555977c037dceb'; + +/// The live `/nodes/aggregated` fan-out result, polled every 25s. Shared +/// by the Dashboard's "Fleet" stat tile and the Fleet screen's card grid +/// (M6) — one poll, cached by Riverpod, rather than each screen running +/// its own duplicate timer against the same endpoint. + +abstract class _$AggregatedNodes + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref< + AsyncValue>, + List + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/data/repositories/services_repository.dart b/lib/data/repositories/services_repository.dart new file mode 100644 index 0000000..72fc5cf --- /dev/null +++ b/lib/data/repositories/services_repository.dart @@ -0,0 +1,57 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_client_provider.dart'; +import '../../core/network/node_master_api_client.dart'; + +part 'services_repository.g.dart'; + +/// Wraps the `/services/*` endpoints — the compose-based services on this +/// host and their start/stop/backup actions. +class ServicesRepository { + const ServicesRepository(this._client); + + final NodeMasterApiClient _client; + + Future> getAll() => _client.getAllServices(); + Future get(String id) => _client.getService(id); + Future add(Service service) => _client.addService(service); + Future update(String id, Service service) => _client.updateService(id, service); + Future delete(String id) => _client.deleteService(id); + Future start(String id) => _client.startService(id); + Future stop(String id) => _client.stopService(id); + Future backupNow(String id) => _client.backupService(id); + + Future> getBackupTargets(String id) => _client.getServiceBackupTargets(id); + Future addBackupTarget(String id, BackupTarget target) => + _client.addServiceBackupTarget(id, target); + Future updateBackupTarget(String id, String targetId, BackupTarget target) => + _client.updateServiceBackupTarget(id, targetId, target); + Future deleteBackupTarget(String id, String targetId) => + _client.deleteServiceBackupTarget(id, targetId); + Future runBackupTarget(String id, String targetId) => + _client.runServiceBackupTarget(id, targetId); + Future getBackupTargetProgress(String id, String targetId) => + _client.getServiceBackupTargetProgress(id, targetId); + Future> getBackupProgress(String id) => _client.getServiceBackupProgress(id); +} + +/// Null when no connection is active, mirroring [apiClientProvider]. +@riverpod +ServicesRepository? servicesRepository(Ref ref) { + final client = ref.watch(apiClientProvider); + if (client == null) return null; + return ServicesRepository(client); +} + +/// The full services list for the active connection. Manual-refresh only +/// (not polled) — this is a detail-heavy table screen where a mid-edit +/// slide-over silently refreshing underneath the user would be worse than +/// slightly stale data; Dashboard's polled summary is where "did something +/// change" gets noticed at a glance. +@riverpod +Future> servicesList(Ref ref) async { + final repo = ref.watch(servicesRepositoryProvider); + if (repo == null) return []; + return repo.getAll(); +} diff --git a/lib/data/repositories/services_repository.g.dart b/lib/data/repositories/services_repository.g.dart new file mode 100644 index 0000000..daa780a --- /dev/null +++ b/lib/data/repositories/services_repository.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'services_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Null when no connection is active, mirroring [apiClientProvider]. + +@ProviderFor(servicesRepository) +const servicesRepositoryProvider = ServicesRepositoryProvider._(); + +/// Null when no connection is active, mirroring [apiClientProvider]. + +final class ServicesRepositoryProvider + extends + $FunctionalProvider< + ServicesRepository?, + ServicesRepository?, + ServicesRepository? + > + with $Provider { + /// Null when no connection is active, mirroring [apiClientProvider]. + const ServicesRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'servicesRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$servicesRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + ServicesRepository? create(Ref ref) { + return servicesRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ServicesRepository? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$servicesRepositoryHash() => + r'6eab348f1911803394716fd8f90a079bfe5cbda1'; + +/// The full services list for the active connection. Manual-refresh only +/// (not polled) — this is a detail-heavy table screen where a mid-edit +/// slide-over silently refreshing underneath the user would be worse than +/// slightly stale data; Dashboard's polled summary is where "did something +/// change" gets noticed at a glance. + +@ProviderFor(servicesList) +const servicesListProvider = ServicesListProvider._(); + +/// The full services list for the active connection. Manual-refresh only +/// (not polled) — this is a detail-heavy table screen where a mid-edit +/// slide-over silently refreshing underneath the user would be worse than +/// slightly stale data; Dashboard's polled summary is where "did something +/// change" gets noticed at a glance. + +final class ServicesListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// The full services list for the active connection. Manual-refresh only + /// (not polled) — this is a detail-heavy table screen where a mid-edit + /// slide-over silently refreshing underneath the user would be worse than + /// slightly stale data; Dashboard's polled summary is where "did something + /// change" gets noticed at a glance. + const ServicesListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'servicesListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$servicesListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return servicesList(ref); + } +} + +String _$servicesListHash() => r'e9d080c9ce0c0796d39c663ba79d7b67a2cf3adc'; diff --git a/lib/features/backups/backups_screen.dart b/lib/features/backups/backups_screen.dart new file mode 100644 index 0000000..093f670 --- /dev/null +++ b/lib/features/backups/backups_screen.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/theme/theme_x.dart'; +import '../../core/widgets/backup_target_form_dialog.dart'; +import '../../core/widgets/backup_targets_list.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/error_banner.dart'; +import '../../core/widgets/sparkline.dart'; +import '../../data/repositories/node_repository.dart'; +import 'widgets/backup_config_form_dialog.dart'; +import 'widgets/backup_history_table.dart'; + +class BackupsScreen extends ConsumerWidget { + const BackupsScreen({super.key}); + + Future _runNow(BuildContext context, WidgetRef ref) async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) return; + try { + await repo.runBackup(); + ref.invalidate(backupConfigProvider); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Backup started'))); + } + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + } + + Future _addTarget(BuildContext context, WidgetRef ref) { + return showBackupTargetFormDialog( + context, + onSave: (draft) async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.addBackupTarget(draft); + ref.invalidate(backupConfigProvider); + }, + ); + } + + Future _editTarget(BuildContext context, WidgetRef ref, BackupTarget target) { + return showBackupTargetFormDialog( + context, + existing: target, + onSave: (draft) async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.updateBackupTarget(target.id, draft); + ref.invalidate(backupConfigProvider); + }, + ); + } + + Future _deleteTarget(BuildContext context, WidgetRef ref, BackupTarget target) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove backup target?'), + content: Text('This removes "${target.name}" from the node\'s backup targets.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.status.critical), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) return; + try { + await repo.deleteBackupTarget(target.id); + ref.invalidate(backupConfigProvider); + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + } + + Future _runTarget(WidgetRef ref, BackupTarget target) async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.runBackupTarget(target.id); + } + + Future _fetchProgress(WidgetRef ref, BackupTarget target) async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) return const RunState(); + return repo.getBackupTargetProgress(target.id); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final configAsync = ref.watch(backupConfigProvider); + + return configAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Center( + child: ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(backupConfigProvider), + ), + ), + data: (config) { + if (config == null) { + return const Center( + child: EmptyState(icon: Icons.cloud_outlined, title: 'No connection configured'), + ); + } + return ListView( + padding: const EdgeInsets.all(24), + children: [ + _ConfigCard(config: config, onRunNow: () => _runNow(context, ref)), + const SizedBox(height: 22), + Text('BACKUP TARGETS', style: context.text.titleSmall), + const SizedBox(height: 10), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: BackupTargetsList( + targets: config.targets, + onAdd: () => _addTarget(context, ref), + onEdit: (target) => _editTarget(context, ref, target), + onDelete: (target) => _deleteTarget(context, ref, target), + onRun: (target) => _runTarget(ref, target), + fetchProgress: (target) => _fetchProgress(ref, target), + onSettled: (target, state) => ref.invalidate(backupConfigProvider), + ), + ), + ), + const SizedBox(height: 22), + _DurationTrendCard(history: config.history), + const SizedBox(height: 22), + Text('EXECUTION HISTORY', style: context.text.titleSmall), + const SizedBox(height: 10), + BackupHistoryTable(history: config.history, targets: config.targets), + ], + ); + }, + ); + } +} + +class _ConfigCard extends StatelessWidget { + const _ConfigCard({required this.config, required this.onRunNow}); + + final BackupConfig config; + final VoidCallback onRunNow; + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('EEE, MMM d · HH:mm'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Node backup configuration', style: context.text.titleMedium), + Row( + children: [ + OutlinedButton.icon( + onPressed: () => showBackupConfigFormDialog(context, existing: config), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('Edit'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: onRunNow, + icon: const Icon(Icons.cloud_outlined, size: 16), + label: const Text('Run all now'), + ), + ], + ), + ], + ), + const Divider(height: 24), + _kv(context, 'Source path', config.sourcePath?.isNotEmpty == true ? config.sourcePath! : 'Not set'), + _kv( + context, + 'Next run', + config.nextRun == null ? 'Not scheduled' : dateFormat.format(config.nextRun!.toLocal()), + ), + _kv( + context, + 'Last run', + config.lastRun == null ? 'Never' : dateFormat.format(config.lastRun!.toLocal()), + ), + ], + ), + ), + ); + } + + Widget _kv(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + SizedBox(width: 120, child: Text(label, style: context.text.bodySmall)), + Expanded(child: Text(value, style: context.text.bodyMedium)), + ], + ), + ); + } +} + +class _DurationTrendCard extends StatelessWidget { + const _DurationTrendCard({required this.history}); + + final List history; + + @override + Widget build(BuildContext context) { + final sorted = [...history]..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final recent = sorted.length > 10 ? sorted.sublist(sorted.length - 10) : sorted; + + if (recent.isEmpty) { + return const SizedBox.shrink(); + } + + final values = [for (final e in recent) e.durationSeconds.toDouble()]; + final latest = recent.last.durationSeconds; + final avg = values.reduce((a, b) => a + b) / values.length; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Sparkline(values: values), + const SizedBox(width: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Duration trend', style: context.text.titleMedium), + const SizedBox(height: 4), + Text( + 'Latest ${latest}s · last ${recent.length}-run avg ${avg.toStringAsFixed(1)}s', + style: context.text.bodySmall, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/backups/widgets/backup_config_form_dialog.dart b/lib/features/backups/widgets/backup_config_form_dialog.dart new file mode 100644 index 0000000..c7533a7 --- /dev/null +++ b/lib/features/backups/widgets/backup_config_form_dialog.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../data/repositories/node_repository.dart'; + +/// Edit dialog for the node-level [BackupConfig]'s source path. Target +/// management (add/edit/remove/run/schedule) lives in the dedicated +/// [BackupTargetsList] on the Backups screen, not in this dialog. +Future showBackupConfigFormDialog(BuildContext context, {required BackupConfig existing}) { + return showDialog( + context: context, + builder: (context) => _BackupConfigFormDialog(existing: existing), + ); +} + +class _BackupConfigFormDialog extends ConsumerStatefulWidget { + const _BackupConfigFormDialog({required this.existing}); + + final BackupConfig existing; + + @override + ConsumerState<_BackupConfigFormDialog> createState() => _BackupConfigFormDialogState(); +} + +class _BackupConfigFormDialogState extends ConsumerState<_BackupConfigFormDialog> { + late final _sourcePathController = TextEditingController(text: widget.existing.sourcePath); + bool _saving = false; + String? _error; + + @override + void dispose() { + _sourcePathController.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) { + setState(() { + _saving = false; + _error = 'No active connection.'; + }); + return; + } + + final draft = widget.existing.copyWith(sourcePath: _sourcePathController.text.trim()); + + try { + await repo.updateBackup(draft); + ref.invalidate(backupConfigProvider); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = e is ApiException ? e.userMessage : e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit backup configuration'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _sourcePathController, + decoration: const InputDecoration(labelText: 'Source path', hintText: '/opt'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/backups/widgets/backup_history_table.dart b/lib/features/backups/widgets/backup_history_table.dart new file mode 100644 index 0000000..fc5931f --- /dev/null +++ b/lib/features/backups/widgets/backup_history_table.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/data_table_scaffold.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/mono_text.dart'; +import '../../../core/widgets/status_pill.dart'; + +class BackupHistoryTable extends StatelessWidget { + const BackupHistoryTable({super.key, required this.history, required this.targets}); + + final List history; + final List targets; + + String _targetName(String? targetId) { + if (targetId == null) return '—'; + for (final t in targets) { + if (t.id == targetId) return t.name; + } + return targetId; + } + + @override + Widget build(BuildContext context) { + final sorted = [...history]..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final timeFormat = DateFormat('yyyy-MM-dd HH:mm'); + + return DataTableScaffold( + empty: const EmptyState( + icon: Icons.cloud_outlined, + title: 'No backup history yet', + message: 'Run a backup to see execution results here.', + ), + columns: const [ + DataColumn(label: Text('Timestamp')), + DataColumn(label: Text('Target')), + DataColumn(label: Text('Result')), + DataColumn(label: Text('Duration')), + DataColumn(label: Text('Message')), + ], + rows: [ + for (final execution in sorted) + DataRow( + cells: [ + DataCell(MonoText(timeFormat.format(execution.timestamp.toLocal()))), + DataCell(Text(_targetName(execution.targetId))), + DataCell( + StatusPill( + label: execution.success ? 'success' : 'failed', + severity: severityForSuccess(execution.success), + ), + ), + DataCell( + Text( + '${execution.durationSeconds}s', + style: context.dataStyles.numericTabular, + ), + ), + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: MonoText(execution.message, small: true, maxLines: 1), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart new file mode 100644 index 0000000..e4d9dd6 --- /dev/null +++ b/lib/features/dashboard/dashboard_screen.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/models.dart'; +import '../../core/models/severity_mapping.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/theme/status_colors.dart'; +import '../../core/theme/theme_x.dart'; +import '../../core/utils/relative_time.dart'; +import '../../core/widgets/error_banner.dart'; +import '../../core/widgets/stat_tile.dart'; +import '../../data/repositories/nodes_repository.dart'; +import 'providers/dashboard_summary.dart'; +import 'providers/dashboard_summary_provider.dart'; +import 'widgets/activity_feed.dart'; +import 'widgets/quick_actions_row.dart'; + +class DashboardScreen extends ConsumerWidget { + const DashboardScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summaryAsync = ref.watch(dashboardSummaryProvider); + final fleetAsync = ref.watch(aggregatedNodesProvider); + + return summaryAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Center( + child: ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(dashboardSummaryProvider), + ), + ), + data: (summary) => ListView( + padding: const EdgeInsets.all(24), + children: [ + _StatRow(summary: summary, fleetAsync: fleetAsync), + const SizedBox(height: 22), + Text('QUICK ACTIONS', style: context.text.titleSmall), + const SizedBox(height: 10), + const QuickActionsRow(), + const SizedBox(height: 22), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('RECENT ACTIVITY', style: context.text.titleSmall), + ], + ), + const SizedBox(height: 10), + Card(child: ActivityFeed(node: summary.node)), + ], + ), + ); + } +} + +class _StatRow extends StatelessWidget { + const _StatRow({required this.summary, required this.fleetAsync}); + + final DashboardSummary summary; + final AsyncValue> fleetAsync; + + @override + Widget build(BuildContext context) { + final services = summary.services; + final up = services.where((s) => s.status == 'up').length; + final down = services.where((s) => s.status == 'down').length; + final unknown = services.length - up - down; + + final servicesSeverity = down > 0 + ? Severity.critical + : unknown > 0 + ? Severity.warning + : Severity.good; + final servicesCaption = services.isEmpty + ? 'No services discovered yet' + : [ + if (down > 0) '$down down', + if (unknown > 0) '$unknown unknown', + ].isEmpty + ? 'All running' + : [if (down > 0) '$down down', if (unknown > 0) '$unknown unknown'].join(' · '); + + final history = [...summary.node.backup.history] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final lastBackup = history.isEmpty ? null : history.first; + + final updates = [...summary.node.updateHistory]..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final lastUpdate = updates.isEmpty ? null : updates.first; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: 260, + child: StatTile( + label: 'Services', + value: '$up', + valueQualifier: '/ ${services.length} up', + caption: servicesCaption, + severity: services.isEmpty ? Severity.neutral : servicesSeverity, + ), + ), + SizedBox( + width: 260, + child: StatTile( + label: 'Last backup', + value: lastBackup == null ? 'Never' : formatRelative(lastBackup.timestamp), + caption: lastBackup == null + ? 'No backups run yet' + : (lastBackup.success ? '${lastBackup.durationSeconds}s' : lastBackup.message), + severity: lastBackup == null ? Severity.neutral : severityForSuccess(lastBackup.success), + ), + ), + SizedBox( + width: 260, + child: StatTile( + label: 'Last system update', + value: lastUpdate == null ? 'Never' : formatRelative(lastUpdate.timestamp), + caption: lastUpdate == null + ? 'No updates recorded yet' + : '${lastUpdate.packages.length} package(s)', + severity: lastUpdate == null ? Severity.neutral : severityForSuccess(lastUpdate.success), + ), + ), + SizedBox(width: 260, child: _FleetTile(fleetAsync: fleetAsync)), + ], + ); + } +} + +class _FleetTile extends StatelessWidget { + const _FleetTile({required this.fleetAsync}); + + final AsyncValue> fleetAsync; + + @override + Widget build(BuildContext context) { + return fleetAsync.when( + loading: () => const StatTile( + label: 'Fleet', + value: '…', + severity: Severity.neutral, + ), + error: (error, stackTrace) => StatTile( + label: 'Fleet', + value: '—', + caption: error is ApiException ? error.userMessage : error.toString(), + severity: Severity.critical, + ), + data: (nodes) { + if (nodes.isEmpty) { + return const StatTile( + label: 'Fleet', + value: '0', + caption: 'No nodes registered', + severity: Severity.neutral, + ); + } + final online = nodes.where((n) => n.error == null).length; + final unreachable = nodes.where((n) => n.error != null).toList(); + return StatTile( + label: 'Fleet', + value: '$online', + valueQualifier: '/ ${nodes.length} online', + caption: unreachable.isEmpty + ? 'All nodes reachable' + : 'unreachable: ${unreachable.map((n) => n.node.name).join(', ')}', + severity: unreachable.isEmpty ? Severity.good : Severity.critical, + ); + }, + ); + } +} diff --git a/lib/features/dashboard/providers/dashboard_summary.dart b/lib/features/dashboard/providers/dashboard_summary.dart new file mode 100644 index 0000000..d4cf092 --- /dev/null +++ b/lib/features/dashboard/providers/dashboard_summary.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../core/models/models.dart'; + +part 'dashboard_summary.freezed.dart'; + +/// Client-side composition of the two data sources the Dashboard needs — +/// no server endpoint returns this shape directly. Not JSON-serializable +/// (nothing here is ever sent/stored as JSON), so no `fromJson`/`toJson`. +@freezed +sealed class DashboardSummary with _$DashboardSummary { + const factory DashboardSummary({required List services, required NodeInfo node}) = + _DashboardSummary; +} diff --git a/lib/features/dashboard/providers/dashboard_summary.freezed.dart b/lib/features/dashboard/providers/dashboard_summary.freezed.dart new file mode 100644 index 0000000..b3ca207 --- /dev/null +++ b/lib/features/dashboard/providers/dashboard_summary.freezed.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'dashboard_summary.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DashboardSummary { + + List get services; NodeInfo get node; +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DashboardSummaryCopyWith get copyWith => _$DashboardSummaryCopyWithImpl(this as DashboardSummary, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DashboardSummary&&const DeepCollectionEquality().equals(other.services, services)&&(identical(other.node, node) || other.node == node)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(services),node); + +@override +String toString() { + return 'DashboardSummary(services: $services, node: $node)'; +} + + +} + +/// @nodoc +abstract mixin class $DashboardSummaryCopyWith<$Res> { + factory $DashboardSummaryCopyWith(DashboardSummary value, $Res Function(DashboardSummary) _then) = _$DashboardSummaryCopyWithImpl; +@useResult +$Res call({ + List services, NodeInfo node +}); + + +$NodeInfoCopyWith<$Res> get node; + +} +/// @nodoc +class _$DashboardSummaryCopyWithImpl<$Res> + implements $DashboardSummaryCopyWith<$Res> { + _$DashboardSummaryCopyWithImpl(this._self, this._then); + + final DashboardSummary _self; + final $Res Function(DashboardSummary) _then; + +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? services = null,Object? node = null,}) { + return _then(_self.copyWith( +services: null == services ? _self.services : services // ignore: cast_nullable_to_non_nullable +as List,node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable +as NodeInfo, + )); +} +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$NodeInfoCopyWith<$Res> get node { + + return $NodeInfoCopyWith<$Res>(_self.node, (value) { + return _then(_self.copyWith(node: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [DashboardSummary]. +extension DashboardSummaryPatterns on DashboardSummary { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _DashboardSummary value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DashboardSummary() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _DashboardSummary value) $default,){ +final _that = this; +switch (_that) { +case _DashboardSummary(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _DashboardSummary value)? $default,){ +final _that = this; +switch (_that) { +case _DashboardSummary() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List services, NodeInfo node)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DashboardSummary() when $default != null: +return $default(_that.services,_that.node);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List services, NodeInfo node) $default,) {final _that = this; +switch (_that) { +case _DashboardSummary(): +return $default(_that.services,_that.node);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List services, NodeInfo node)? $default,) {final _that = this; +switch (_that) { +case _DashboardSummary() when $default != null: +return $default(_that.services,_that.node);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _DashboardSummary implements DashboardSummary { + const _DashboardSummary({required final List services, required this.node}): _services = services; + + + final List _services; +@override List get services { + if (_services is EqualUnmodifiableListView) return _services; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_services); +} + +@override final NodeInfo node; + +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DashboardSummaryCopyWith<_DashboardSummary> get copyWith => __$DashboardSummaryCopyWithImpl<_DashboardSummary>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DashboardSummary&&const DeepCollectionEquality().equals(other._services, _services)&&(identical(other.node, node) || other.node == node)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_services),node); + +@override +String toString() { + return 'DashboardSummary(services: $services, node: $node)'; +} + + +} + +/// @nodoc +abstract mixin class _$DashboardSummaryCopyWith<$Res> implements $DashboardSummaryCopyWith<$Res> { + factory _$DashboardSummaryCopyWith(_DashboardSummary value, $Res Function(_DashboardSummary) _then) = __$DashboardSummaryCopyWithImpl; +@override @useResult +$Res call({ + List services, NodeInfo node +}); + + +@override $NodeInfoCopyWith<$Res> get node; + +} +/// @nodoc +class __$DashboardSummaryCopyWithImpl<$Res> + implements _$DashboardSummaryCopyWith<$Res> { + __$DashboardSummaryCopyWithImpl(this._self, this._then); + + final _DashboardSummary _self; + final $Res Function(_DashboardSummary) _then; + +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? services = null,Object? node = null,}) { + return _then(_DashboardSummary( +services: null == services ? _self._services : services // ignore: cast_nullable_to_non_nullable +as List,node: null == node ? _self.node : node // ignore: cast_nullable_to_non_nullable +as NodeInfo, + )); +} + +/// Create a copy of DashboardSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$NodeInfoCopyWith<$Res> get node { + + return $NodeInfoCopyWith<$Res>(_self.node, (value) { + return _then(_self.copyWith(node: value)); + }); +} +} + +// dart format on diff --git a/lib/features/dashboard/providers/dashboard_summary_provider.dart b/lib/features/dashboard/providers/dashboard_summary_provider.dart new file mode 100644 index 0000000..d601a56 --- /dev/null +++ b/lib/features/dashboard/providers/dashboard_summary_provider.dart @@ -0,0 +1,39 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/network/api_exception.dart'; +import '../../../core/polling/polling_async_notifier.dart'; +import '../../../data/repositories/node_repository.dart'; +import '../../../data/repositories/services_repository.dart'; +import 'dashboard_summary.dart'; + +part 'dashboard_summary_provider.g.dart'; + +/// Polled every 20s while the Dashboard is mounted — deliberately its own +/// notifier rather than watching `servicesListProvider`/`currentNodeProvider` +/// directly, so Dashboard auto-refreshes independent of whether the +/// Services screen (manual-refresh only) happens to be open too. +@riverpod +class DashboardSummaryNotifier extends _$DashboardSummaryNotifier + with PollingMixin { + @override + Duration get interval => const Duration(seconds: 20); + + @override + Future fetch() async { + final servicesRepo = ref.watch(servicesRepositoryProvider); + final nodeRepo = ref.watch(nodeRepositoryProvider); + if (servicesRepo == null || nodeRepo == null) { + // The router redirects to Settings whenever there's no active + // connection, so reaching this in the UI would mean that redirect + // hasn't fired yet — an ApiException here (vs. a raw StateError) + // keeps error handling on the same path as every other screen. + throw const ApiException.unknown('No active connection.'); + } + final services = await servicesRepo.getAll(); + final node = await nodeRepo.getNode(); + return DashboardSummary(services: services, node: node); + } + + @override + Future build() => startPolling(); +} diff --git a/lib/features/dashboard/providers/dashboard_summary_provider.g.dart b/lib/features/dashboard/providers/dashboard_summary_provider.g.dart new file mode 100644 index 0000000..29300f8 --- /dev/null +++ b/lib/features/dashboard/providers/dashboard_summary_provider.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dashboard_summary_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Polled every 20s while the Dashboard is mounted — deliberately its own +/// notifier rather than watching `servicesListProvider`/`currentNodeProvider` +/// directly, so Dashboard auto-refreshes independent of whether the +/// Services screen (manual-refresh only) happens to be open too. + +@ProviderFor(DashboardSummaryNotifier) +const dashboardSummaryProvider = DashboardSummaryNotifierProvider._(); + +/// Polled every 20s while the Dashboard is mounted — deliberately its own +/// notifier rather than watching `servicesListProvider`/`currentNodeProvider` +/// directly, so Dashboard auto-refreshes independent of whether the +/// Services screen (manual-refresh only) happens to be open too. +final class DashboardSummaryNotifierProvider + extends $AsyncNotifierProvider { + /// Polled every 20s while the Dashboard is mounted — deliberately its own + /// notifier rather than watching `servicesListProvider`/`currentNodeProvider` + /// directly, so Dashboard auto-refreshes independent of whether the + /// Services screen (manual-refresh only) happens to be open too. + const DashboardSummaryNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dashboardSummaryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dashboardSummaryNotifierHash(); + + @$internal + @override + DashboardSummaryNotifier create() => DashboardSummaryNotifier(); +} + +String _$dashboardSummaryNotifierHash() => + r'807d0df3c8b7ce9652ae56fdba542b108aa99456'; + +/// Polled every 20s while the Dashboard is mounted — deliberately its own +/// notifier rather than watching `servicesListProvider`/`currentNodeProvider` +/// directly, so Dashboard auto-refreshes independent of whether the +/// Services screen (manual-refresh only) happens to be open too. + +abstract class _$DashboardSummaryNotifier + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref as $Ref, DashboardSummary>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, DashboardSummary>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/dashboard/widgets/activity_feed.dart b/lib/features/dashboard/widgets/activity_feed.dart new file mode 100644 index 0000000..93c7bc8 --- /dev/null +++ b/lib/features/dashboard/widgets/activity_feed.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/theme/status_colors.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/utils/relative_time.dart'; +import '../../../core/widgets/empty_state.dart'; + +class _FeedEntry { + const _FeedEntry({ + required this.timestamp, + required this.title, + required this.subtitle, + required this.severity, + required this.icon, + }); + + final DateTime timestamp; + final String title; + final String subtitle; + final Severity severity; + final IconData icon; +} + +/// Merges backup executions and OS update records from [NodeInfo] into one +/// reverse-chronological feed. There's no persisted scan-result log on the +/// server to fold in here — only these two histories actually exist. +class ActivityFeed extends StatelessWidget { + const ActivityFeed({super.key, required this.node, this.maxEntries = 6}); + + final NodeInfo node; + final int maxEntries; + + List<_FeedEntry> _buildEntries() { + final entries = <_FeedEntry>[ + for (final execution in node.backup.history) + _FeedEntry( + timestamp: execution.timestamp, + title: execution.success ? 'Backup completed' : 'Backup failed', + subtitle: execution.message.isEmpty + ? '${execution.durationSeconds}s' + : execution.message, + severity: severityForSuccess(execution.success), + icon: Icons.cloud_outlined, + ), + for (final update in node.updateHistory) + _FeedEntry( + timestamp: update.timestamp, + title: update.success ? 'System update applied' : 'System update failed', + subtitle: update.packages.isEmpty + ? update.message + : '${update.packages.length} package(s)${update.message.isEmpty ? '' : ' · ${update.message}'}', + severity: severityForSuccess(update.success), + icon: Icons.history_rounded, + ), + ]..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return entries.take(maxEntries).toList(); + } + + @override + Widget build(BuildContext context) { + final entries = _buildEntries(); + + if (entries.isEmpty) { + return const EmptyState( + icon: Icons.history_rounded, + title: 'No activity yet', + message: 'Backup runs and system updates will show up here.', + ); + } + + return Column( + children: [ + for (var i = 0; i < entries.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _FeedRow(entry: entries[i]), + ], + ], + ); + } +} + +class _FeedRow extends StatelessWidget { + const _FeedRow({required this.entry}); + + final _FeedEntry entry; + + @override + Widget build(BuildContext context) { + final color = entry.severity.resolve(context.status, muted: context.colors.outline); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Color.alphaBlend(color.withValues(alpha: 0.14), context.colors.surface), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(entry.icon, size: 14, color: color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(entry.title, style: context.text.labelLarge), + Text( + entry.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.text.bodySmall, + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + formatRelative(entry.timestamp), + style: context.dataStyles.numericTabular.copyWith(fontSize: 12, color: context.colors.outline), + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/quick_actions_row.dart b/lib/features/dashboard/widgets/quick_actions_row.dart new file mode 100644 index 0000000..05a6b0e --- /dev/null +++ b/lib/features/dashboard/widgets/quick_actions_row.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_exception.dart'; +import '../../../data/repositories/node_repository.dart'; +import '../../../data/repositories/nodes_repository.dart'; +import '../providers/dashboard_summary_provider.dart'; + +class QuickActionsRow extends ConsumerStatefulWidget { + const QuickActionsRow({super.key}); + + @override + ConsumerState createState() => _QuickActionsRowState(); +} + +class _QuickActionsRowState extends ConsumerState { + bool _busy = false; + + Future _guarded(Future Function() action) async { + setState(() => _busy = true); + try { + final message = await action(); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _scan() => _guarded(() async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) throw StateError('No active connection.'); + final result = await repo.scan(); + await ref.read(dashboardSummaryProvider.notifier).refresh(); + final changed = result.created.length + result.updated.length; + return changed == 0 + ? 'Scan complete — no changes (${result.found} compose file(s) found)' + : 'Scan complete — ${result.created.length} new, ${result.updated.length} updated'; + }); + + Future _runBackup() => _guarded(() async { + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) throw StateError('No active connection.'); + await repo.runBackup(); + await ref.read(dashboardSummaryProvider.notifier).refresh(); + return 'Backup started'; + }); + + Future _recheckFleet() => _guarded(() async { + await ref.read(aggregatedNodesProvider.notifier).refresh(); + return 'Fleet status refreshed'; + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _scan, + icon: const Icon(Icons.search_rounded, size: 17), + label: const Text('Scan for services'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _runBackup, + icon: const Icon(Icons.cloud_outlined, size: 17), + label: const Text('Run backup now'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _recheckFleet, + icon: const Icon(Icons.refresh_rounded, size: 17), + label: const Text('Re-check fleet'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/fleet/fleet_screen.dart b/lib/features/fleet/fleet_screen.dart new file mode 100644 index 0000000..d877260 --- /dev/null +++ b/lib/features/fleet/fleet_screen.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/theme/theme_x.dart'; +import '../../core/widgets/error_banner.dart'; +import '../../data/repositories/nodes_repository.dart'; +import 'widgets/fleet_card_grid.dart'; +import 'widgets/fleet_table.dart'; +import 'widgets/node_form_dialog.dart'; + +class FleetScreen extends ConsumerWidget { + const FleetScreen({super.key}); + + Future _delete(BuildContext context, WidgetRef ref, RemoteNode node) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove node?'), + content: Text('This removes "${node.name}" from the registry. It does not affect that host.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.status.critical), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repo = ref.read(nodesRepositoryProvider); + if (repo == null) return; + try { + await repo.delete(node.id); + ref.invalidate(remoteNodesListProvider); + ref.invalidate(aggregatedNodesProvider); + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nodesAsync = ref.watch(remoteNodesListProvider); + final aggregatedAsync = ref.watch(aggregatedNodesProvider); + + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('REGISTERED NODES', style: context.text.titleSmall), + FilledButton.icon( + onPressed: () => showNodeFormDialog(context), + icon: const Icon(Icons.add, size: 17), + label: const Text('Register node'), + ), + ], + ), + const SizedBox(height: 10), + nodesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stackTrace) => ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(remoteNodesListProvider), + ), + data: (nodes) => FleetTable( + nodes: nodes, + aggregated: aggregatedAsync.value, + onEdit: (node) => showNodeFormDialog(context, existing: node), + onDelete: (node) => _delete(context, ref, node), + ), + ), + const SizedBox(height: 22), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('AGGREGATED SERVICES', style: context.text.titleSmall), + Text( + 'live fan-out via /nodes/aggregated', + style: context.dataStyles.dataMonoSmall, + ), + ], + ), + const SizedBox(height: 10), + aggregatedAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stackTrace) => ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(aggregatedNodesProvider), + ), + data: (entries) => FleetCardGrid(entries: entries), + ), + ], + ); + } +} diff --git a/lib/features/fleet/widgets/fleet_card_grid.dart b/lib/features/fleet/widgets/fleet_card_grid.dart new file mode 100644 index 0000000..7bf8cd4 --- /dev/null +++ b/lib/features/fleet/widgets/fleet_card_grid.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/status_pill.dart'; + +/// One card per registered node showing its live services (via +/// `/nodes/aggregated`), or an inline error card if this particular node +/// was unreachable — a per-entry condition the local API itself reports, +/// distinct from the aggregated fetch as a whole failing. +class FleetCardGrid extends StatelessWidget { + const FleetCardGrid({super.key, required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return const EmptyState( + icon: Icons.hub_outlined, + title: 'Nothing to show yet', + message: 'Register a node to see its live services here.', + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 180, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: entries.length, + itemBuilder: (context, index) => _NodeCard(entry: entries[index]), + ); + } +} + +class _NodeCard extends StatelessWidget { + const _NodeCard({required this.entry}); + + final AggregatedNodeStatus entry; + + @override + Widget build(BuildContext context) { + final hasError = entry.error != null; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: hasError ? context.status.critical.withValues(alpha: 0.4) : context.colors.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.node.name, style: context.text.labelLarge), + const SizedBox(height: 10), + Expanded( + child: hasError + ? _ErrorCallout(message: entry.error!) + : entry.services.isEmpty + ? Text('No services reported.', style: context.text.bodySmall) + : ListView( + padding: EdgeInsets.zero, + children: [ + for (final service in entry.services) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3.5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + service.name, + overflow: TextOverflow.ellipsis, + style: context.text.bodyMedium, + ), + ), + StatusPill( + label: service.status, + severity: severityForServiceStatus(service.status), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ErrorCallout extends StatelessWidget { + const _ErrorCallout({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.status.critical.withValues(alpha: 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: 15, color: context.status.critical), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: context.dataStyles.dataMonoSmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/fleet/widgets/fleet_table.dart b/lib/features/fleet/widgets/fleet_table.dart new file mode 100644 index 0000000..94c3bfe --- /dev/null +++ b/lib/features/fleet/widgets/fleet_table.dart @@ -0,0 +1,120 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/theme/status_colors.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/utils/relative_time.dart'; +import '../../../core/widgets/data_table_scaffold.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/mono_text.dart'; +import '../../../core/widgets/status_pill.dart'; + +class FleetTable extends StatelessWidget { + const FleetTable({ + super.key, + required this.nodes, + required this.aggregated, + required this.onEdit, + required this.onDelete, + }); + + final List nodes; + + /// Null while the aggregated fetch is still loading/erroring — reachability + /// then shows as "unknown" rather than guessing. + final List? aggregated; + final void Function(RemoteNode node) onEdit; + final void Function(RemoteNode node) onDelete; + + ({String label, Severity severity}) _reachability(RemoteNode node) { + final entry = aggregated?.where((a) => a.node.id == node.id).firstOrNull; + if (entry == null) return (label: 'unknown', severity: Severity.neutral); + return entry.error == null + ? (label: 'online', severity: Severity.good) + : (label: 'unreachable', severity: Severity.critical); + } + + @override + Widget build(BuildContext context) { + return DataTableScaffold( + empty: const EmptyState( + icon: Icons.hub_outlined, + title: 'No nodes registered', + message: 'Register a sibling NodeMaster host to see it here.', + ), + columns: const [ + DataColumn(label: Text('Node')), + DataColumn(label: Text('URL')), + DataColumn(label: Text('Tags')), + DataColumn(label: Text('Status')), + DataColumn(label: Text('Last seen')), + DataColumn(label: Text('')), + ], + rows: [ + for (final node in nodes) + DataRow( + cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(node.name, style: context.text.labelLarge), + if (node.description?.isNotEmpty == true) + Text(node.description!, style: context.text.bodySmall), + ], + ), + ), + DataCell(MonoText(node.url)), + DataCell( + Wrap( + spacing: 4, + children: [ + for (final tag in node.tags) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text(tag, style: context.dataStyles.dataMonoSmall), + ), + ], + ), + ), + DataCell(Builder( + builder: (context) { + final r = _reachability(node); + return StatusPill(label: r.label, severity: r.severity); + }, + )), + DataCell( + Text( + node.lastSeen == null ? '—' : formatRelative(node.lastSeen!.toLocal()), + style: context.text.bodySmall, + ), + ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => onEdit(node), + ), + IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => onDelete(node), + ), + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/fleet/widgets/node_form_dialog.dart b/lib/features/fleet/widgets/node_form_dialog.dart new file mode 100644 index 0000000..aa496f6 --- /dev/null +++ b/lib/features/fleet/widgets/node_form_dialog.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../data/repositories/nodes_repository.dart'; + +/// Add/edit dialog for a registered [RemoteNode]. This only edits the +/// registry entry (name/url/description/tags) — reachability is never +/// user-editable, it's always computed live via `/nodes/aggregated`. +Future showNodeFormDialog(BuildContext context, {RemoteNode? existing}) { + return showDialog( + context: context, + builder: (context) => _NodeFormDialog(existing: existing), + ); +} + +class _NodeFormDialog extends ConsumerStatefulWidget { + const _NodeFormDialog({this.existing}); + + final RemoteNode? existing; + + @override + ConsumerState<_NodeFormDialog> createState() => _NodeFormDialogState(); +} + +class _NodeFormDialogState extends ConsumerState<_NodeFormDialog> { + final _formKey = GlobalKey(); + late final _nameController = TextEditingController(text: widget.existing?.name); + late final _urlController = TextEditingController(text: widget.existing?.url); + late final _descriptionController = TextEditingController(text: widget.existing?.description); + late final _tagsController = TextEditingController(text: widget.existing?.tags.join(', ')); + bool _saving = false; + String? _error; + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _descriptionController.dispose(); + _tagsController.dispose(); + super.dispose(); + } + + String? _validateUrl(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Required.'; + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + return 'Enter a full URL, e.g. https://edge-02.local:8080'; + } + return null; + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _saving = true; + _error = null; + }); + + final repo = ref.read(nodesRepositoryProvider); + if (repo == null) { + setState(() { + _saving = false; + _error = 'No active connection.'; + }); + return; + } + + var url = _urlController.text.trim(); + if (url.endsWith('/')) url = url.substring(0, url.length - 1); + final tags = _tagsController.text + .split(',') + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toList(); + final description = _descriptionController.text.trim(); + + final draft = (widget.existing ?? const RemoteNode(name: '', url: '')).copyWith( + name: _nameController.text.trim(), + url: url, + description: description.isEmpty ? null : description, + tags: tags, + ); + + try { + if (widget.existing == null) { + await repo.add(draft); + } else { + await repo.update(widget.existing!.id, draft); + } + ref.invalidate(remoteNodesListProvider); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = e is ApiException ? e.userMessage : e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.existing == null ? 'Register node' : 'Edit node'), + content: Form( + key: _formKey, + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Base URL', + hintText: 'https://edge-02.local:8080', + ), + validator: _validateUrl, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 14), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration(labelText: 'Description (optional)'), + ), + const SizedBox(height: 14), + TextFormField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (optional, comma-separated)', + hintText: 'prod, db', + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/services/services_screen.dart b/lib/features/services/services_screen.dart new file mode 100644 index 0000000..1004797 --- /dev/null +++ b/lib/features/services/services_screen.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/models/models.dart'; +import '../../core/models/severity_mapping.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/routing/route_paths.dart'; +import '../../core/theme/theme_x.dart'; +import '../../core/widgets/error_banner.dart'; +import '../../core/widgets/slide_over_panel.dart'; +import '../../core/widgets/status_pill.dart'; +import '../../data/repositories/services_repository.dart'; +import 'widgets/service_detail_body.dart'; +import 'widgets/service_form_dialog.dart'; +import 'widgets/services_table.dart'; + +class ServicesScreen extends ConsumerStatefulWidget { + const ServicesScreen({super.key, this.serviceId}); + + /// Present when routed via `/services?id=...` — renders the slide-over + /// detail panel for this service alongside the still-visible table. + final String? serviceId; + + @override + ConsumerState createState() => _ServicesScreenState(); +} + +class _ServicesScreenState extends ConsumerState { + final _busyIds = {}; + + Future _run(String id, Future Function() action, {String? successMessage}) async { + setState(() => _busyIds.add(id)); + try { + await action(); + ref.invalidate(servicesListProvider); + if (mounted && successMessage != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(successMessage))); + } + } catch (e) { + if (mounted) { + final message = e is ApiException ? e.userMessage : e.toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } finally { + if (mounted) setState(() => _busyIds.remove(id)); + } + } + + void _toggleRunning(Service service) { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) return; + _run(service.id, () => service.status == 'up' ? repo.stop(service.id) : repo.start(service.id)); + } + + void _backupNow(Service service) { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) return; + _run(service.id, () => repo.backupNow(service.id), successMessage: 'Backup started'); + } + + Future _delete(Service service) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete service?'), + content: Text('This removes "${service.name}" from NodeMaster. It does not stop or delete the container.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.status.critical), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) return; + await _run(service.id, () => repo.delete(service.id)); + if (mounted) context.go(RoutePaths.services); + } + + @override + Widget build(BuildContext context) { + final servicesAsync = ref.watch(servicesListProvider); + + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + servicesAsync.maybeWhen( + data: (services) => '${services.length} SERVICES', + orElse: () => 'SERVICES', + ), + style: context.text.titleSmall, + ), + FilledButton.icon( + onPressed: () => showServiceFormDialog(context), + icon: const Icon(Icons.add, size: 17), + label: const Text('Add service'), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: servicesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Align( + alignment: Alignment.topCenter, + child: ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(servicesListProvider), + ), + ), + data: (services) => SingleChildScrollView( + child: ServicesTable( + services: services, + busyServiceIds: _busyIds, + onRowTap: (service) => context.go(RoutePaths.serviceDetail(service.id)), + onToggleRunning: _toggleRunning, + ), + ), + ), + ), + ], + ), + ), + _buildDetailPanel(servicesAsync), + ], + ); + } + + Widget _buildDetailPanel(AsyncValue> servicesAsync) { + final id = widget.serviceId; + final service = servicesAsync.maybeWhen( + data: (services) { + for (final s in services) { + if (s.id == id) return s; + } + return null; + }, + orElse: () => null, + ); + + return SlideOverPanel( + open: id != null, + onClose: () => context.go(RoutePaths.services), + title: service?.name ?? '', + subtitle: service == null + ? null + : StatusPill(label: service.status, severity: severityForServiceStatus(service.status)), + body: service == null + ? const SizedBox.shrink() + : ServiceDetailBody(service: service), + headerActions: service == null + ? const [] + : [ + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => showServiceFormDialog(context, existing: service), + ), + ], + actions: service == null + ? const [] + : [ + OutlinedButton.icon( + onPressed: () => _toggleRunning(service), + icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 17), + label: Text(service.status == 'up' ? 'Stop' : 'Start'), + ), + OutlinedButton.icon( + onPressed: service.backup == null ? null : () => _backupNow(service), + icon: const Icon(Icons.cloud_outlined, size: 17), + label: const Text('Backup'), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom(foregroundColor: context.status.critical), + onPressed: () => _delete(service), + icon: const Icon(Icons.delete_outline, size: 17), + label: const Text('Delete'), + ), + ], + ); + } +} diff --git a/lib/features/services/widgets/service_detail_body.dart b/lib/features/services/widgets/service_detail_body.dart new file mode 100644 index 0000000..a675757 --- /dev/null +++ b/lib/features/services/widgets/service_detail_body.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/backup_target_form_dialog.dart'; +import '../../../core/widgets/backup_targets_list.dart'; +import '../../../core/widgets/mono_text.dart'; +import '../../../core/widgets/status_pill.dart'; +import '../../../data/repositories/services_repository.dart'; + +/// The scrollable body of the Services slide-over panel — everything below +/// the title/close row that [SlideOverPanel] already renders. +class ServiceDetailBody extends ConsumerWidget { + const ServiceDetailBody({super.key, required this.service}); + + final Service service; + + Future _addTarget(BuildContext context, WidgetRef ref) { + return showBackupTargetFormDialog( + context, + onSave: (draft) async { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.addBackupTarget(service.id, draft); + ref.invalidate(servicesListProvider); + }, + ); + } + + Future _editTarget(BuildContext context, WidgetRef ref, BackupTarget target) { + return showBackupTargetFormDialog( + context, + existing: target, + onSave: (draft) async { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.updateBackupTarget(service.id, target.id, draft); + ref.invalidate(servicesListProvider); + }, + ); + } + + Future _deleteTarget(BuildContext context, WidgetRef ref, BackupTarget target) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove backup target?'), + content: Text('This removes "${target.name}" from this service\'s backup targets.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.status.critical), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) return; + try { + await repo.deleteBackupTarget(service.id, target.id); + ref.invalidate(servicesListProvider); + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + } + + Future _runTarget(WidgetRef ref, BackupTarget target) async { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) throw const ApiException.unknown('No active connection.'); + await repo.runBackupTarget(service.id, target.id); + } + + Future _fetchProgress(WidgetRef ref, BackupTarget target) async { + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) return const RunState(); + return repo.getBackupTargetProgress(service.id, target.id); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backup = service.backup; + final targets = backup?.targets ?? const []; + final history = (backup?.history ?? const []).reversed.take(5).toList(); + final timeFormat = DateFormat('MM-dd HH:mm'); + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + children: [ + _KvRow(label: 'Compose file', value: MonoText(service.composeFile)), + _KvRow( + label: 'Compose type', + value: Text(service.composeType?.isNotEmpty == true ? service.composeType! : 'docker compose'), + ), + _KvRow( + label: 'Backup folder', + value: service.backupFolder?.isNotEmpty == true + ? MonoText(service.backupFolder!) + : Text('Not set', style: context.text.bodySmall), + ), + _KvRow(label: 'Stop on backup', value: Text(service.stopOnBackup ? 'Yes' : 'No')), + const SizedBox(height: 18), + Text('BACKUP TARGETS', style: context.text.titleSmall), + const SizedBox(height: 4), + BackupTargetsList( + targets: targets, + onAdd: () => _addTarget(context, ref), + onEdit: (target) => _editTarget(context, ref, target), + onDelete: (target) => _deleteTarget(context, ref, target), + onRun: (target) => _runTarget(ref, target), + fetchProgress: (target) => _fetchProgress(ref, target), + onSettled: (target, state) => ref.invalidate(servicesListProvider), + ), + const SizedBox(height: 18), + Text('RECENT BACKUPS', style: context.text.titleSmall), + const SizedBox(height: 4), + if (history.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('No backup history yet.', style: context.text.bodySmall), + ) + else + for (final execution in history) + Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + MonoText(timeFormat.format(execution.timestamp.toLocal()), small: true), + const Spacer(), + StatusPill( + label: execution.success ? '${execution.durationSeconds}s' : 'failed', + severity: severityForSuccess(execution.success), + ), + ], + ), + ), + ], + ); + } +} + +class _KvRow extends StatelessWidget { + const _KvRow({required this.label, required this.value}); + + final String label; + final Widget value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: context.colors.outlineVariant))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 128, child: Text(label, style: context.text.bodySmall)), + Expanded(child: value), + ], + ), + ); + } +} diff --git a/lib/features/services/widgets/service_form_dialog.dart b/lib/features/services/widgets/service_form_dialog.dart new file mode 100644 index 0000000..c520985 --- /dev/null +++ b/lib/features/services/widgets/service_form_dialog.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../data/repositories/services_repository.dart'; + +const _composeTypes = ['docker compose', 'docker-compose', 'podman-compose']; + +/// Add/edit dialog for a [Service]'s core identity fields. Backup target +/// configuration is deliberately out of scope here — that's a node/service +/// backup-config concern owned by the Backups screen, not service CRUD. +Future showServiceFormDialog(BuildContext context, {Service? existing}) { + return showDialog( + context: context, + builder: (context) => _ServiceFormDialog(existing: existing), + ); +} + +class _ServiceFormDialog extends ConsumerStatefulWidget { + const _ServiceFormDialog({this.existing}); + + final Service? existing; + + @override + ConsumerState<_ServiceFormDialog> createState() => _ServiceFormDialogState(); +} + +class _ServiceFormDialogState extends ConsumerState<_ServiceFormDialog> { + final _formKey = GlobalKey(); + late final _nameController = TextEditingController(text: widget.existing?.name); + late final _composeFileController = TextEditingController(text: widget.existing?.composeFile); + late final _backupFolderController = TextEditingController(text: widget.existing?.backupFolder); + late String _composeType = widget.existing?.composeType?.isNotEmpty == true + ? widget.existing!.composeType! + : _composeTypes.first; + late bool _stopOnBackup = widget.existing?.stopOnBackup ?? false; + bool _saving = false; + String? _error; + + @override + void dispose() { + _nameController.dispose(); + _composeFileController.dispose(); + _backupFolderController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _saving = true; + _error = null; + }); + + final existing = widget.existing; + final draft = (existing ?? const Service(name: '', composeFile: '')).copyWith( + name: _nameController.text.trim(), + composeFile: _composeFileController.text.trim(), + composeType: _composeType, + backupFolder: _backupFolderController.text.trim(), + stopOnBackup: _stopOnBackup, + ); + + final repo = ref.read(servicesRepositoryProvider); + if (repo == null) { + setState(() { + _saving = false; + _error = 'No active connection.'; + }); + return; + } + + try { + if (existing == null) { + await repo.add(draft); + } else { + await repo.update(existing.id, draft); + } + ref.invalidate(servicesListProvider); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = e is ApiException ? e.userMessage : e.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.existing == null ? 'Add service' : 'Edit service'), + content: Form( + key: _formKey, + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + ), + const SizedBox(height: 14), + TextFormField( + controller: _composeFileController, + decoration: const InputDecoration( + labelText: 'Compose file', + hintText: '/opt/compose/gitea/docker-compose.yml', + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + ), + const SizedBox(height: 14), + DropdownButtonFormField( + initialValue: _composeType, + decoration: const InputDecoration(labelText: 'Compose type'), + items: [ + for (final type in _composeTypes) DropdownMenuItem(value: type, child: Text(type)), + ], + onChanged: (value) => setState(() => _composeType = value ?? _composeTypes.first), + ), + const SizedBox(height: 14), + TextFormField( + controller: _backupFolderController, + decoration: const InputDecoration( + labelText: 'Backup folder (optional)', + hintText: '/opt/data/containers/gitea', + ), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Stop container while backing up'), + value: _stopOnBackup, + onChanged: (value) => setState(() => _stopOnBackup = value), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/services/widgets/services_table.dart b/lib/features/services/widgets/services_table.dart new file mode 100644 index 0000000..cbac1ad --- /dev/null +++ b/lib/features/services/widgets/services_table.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/data_table_scaffold.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/mono_text.dart'; +import '../../../core/widgets/status_pill.dart'; + +class ServicesTable extends StatelessWidget { + const ServicesTable({ + super.key, + required this.services, + required this.onRowTap, + required this.onToggleRunning, + required this.busyServiceIds, + }); + + final List services; + final void Function(Service service) onRowTap; + final void Function(Service service) onToggleRunning; + final Set busyServiceIds; + + String _backupSummary(Service service) { + final targets = service.backup?.targets ?? const []; + if (targets.isEmpty) return 'none'; + if (targets.length == 1) return targets.first.method.name; + return '${targets.length} targets'; + } + + @override + Widget build(BuildContext context) { + return DataTableScaffold( + empty: const EmptyState( + icon: Icons.layers_outlined, + title: 'No services yet', + message: 'Scan a compose folder from the Dashboard, or add one manually.', + ), + columns: const [ + DataColumn(label: Text('Service')), + DataColumn(label: Text('Status')), + DataColumn(label: Text('Compose file')), + DataColumn(label: Text('Backup')), + DataColumn(label: Text('')), + ], + rows: [ + for (final service in services) + DataRow( + onSelectChanged: (_) => onRowTap(service), + cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(service.name, style: context.text.labelLarge), + Text( + service.composeType?.isNotEmpty == true ? service.composeType! : 'docker compose', + style: context.text.bodySmall, + ), + ], + ), + ), + DataCell( + StatusPill(label: service.status, severity: severityForServiceStatus(service.status)), + ), + DataCell(MonoText(service.composeFile)), + DataCell( + _backupSummary(service) == 'none' + ? Text('none', style: context.text.bodySmall) + : Text(_backupSummary(service), style: context.text.bodyMedium), + ), + DataCell( + busyServiceIds.contains(service.id) + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : IconButton( + tooltip: service.status == 'up' ? 'Stop' : 'Start', + icon: Icon(service.status == 'up' ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 20), + onPressed: () => onToggleRunning(service), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart new file mode 100644 index 0000000..e6f3621 --- /dev/null +++ b/lib/features/settings/settings_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_exception.dart'; +import '../../core/theme/theme_x.dart'; +import '../../data/repositories/node_repository.dart'; +import 'widgets/connection_form_dialog.dart'; +import 'widgets/connections_list.dart'; +import 'widgets/no_auth_callout.dart'; +import 'widgets/node_identity_form.dart'; +import 'widgets/theme_toggle.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + padding: const EdgeInsets.all(24), + children: [ + _Block( + title: 'Connections', + trailing: TextButton.icon( + onPressed: () => showConnectionFormDialog(context), + icon: const Icon(Icons.add, size: 17), + label: const Text('Add connection'), + ), + child: const ConnectionsList(), + ), + const SizedBox(height: 22), + _Block(title: 'Node identity', child: const _NodeIdentitySection()), + const SizedBox(height: 22), + _Block( + title: 'Appearance', + child: const Padding( + padding: EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Theme'), + ThemeToggle(), + ], + ), + ), + ), + const SizedBox(height: 22), + const NoAuthCallout(), + ], + ); + } +} + +class _NodeIdentitySection extends ConsumerWidget { + const _NodeIdentitySection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nodeAsync = ref.watch(currentNodeProvider); + + return nodeAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), + SizedBox(width: 10), + Text('Contacting node…'), + ], + ), + ), + error: (error, stackTrace) { + final message = error is ApiException ? error.userMessage : error.toString(); + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: 18, color: context.status.critical), + const SizedBox(width: 10), + Expanded( + child: Text(message, style: context.text.bodyMedium?.copyWith(color: context.status.critical)), + ), + ], + ), + ); + }, + data: (node) { + if (node == null) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('No connection selected.'), + ); + } + return NodeIdentityForm(node: node); + }, + ); + } +} + +class _Block extends StatelessWidget { + const _Block({required this.title, required this.child, this.trailing}); + + final String title; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: context.text.titleMedium), + trailing ?? const SizedBox.shrink(), + ], + ), + const SizedBox(height: 10), + Card(child: child), + ], + ); + } +} diff --git a/lib/features/settings/widgets/connection_form_dialog.dart b/lib/features/settings/widgets/connection_form_dialog.dart new file mode 100644 index 0000000..0b09809 --- /dev/null +++ b/lib/features/settings/widgets/connection_form_dialog.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/connections/connection.dart'; +import '../../../core/connections/connection_providers.dart'; + +/// Add/edit dialog for a saved [Connection]. Passing [existing] switches it +/// to edit mode; omitting it creates a new connection and makes it active +/// (the common "just point me at a node" first-run flow). +Future showConnectionFormDialog( + BuildContext context, { + Connection? existing, +}) { + return showDialog( + context: context, + builder: (context) => _ConnectionFormDialog(existing: existing), + ); +} + +class _ConnectionFormDialog extends ConsumerStatefulWidget { + const _ConnectionFormDialog({this.existing}); + + final Connection? existing; + + @override + ConsumerState<_ConnectionFormDialog> createState() => _ConnectionFormDialogState(); +} + +class _ConnectionFormDialogState extends ConsumerState<_ConnectionFormDialog> { + final _formKey = GlobalKey(); + late final _nameController = TextEditingController(text: widget.existing?.name); + late final _urlController = TextEditingController(text: widget.existing?.baseUrl); + bool _saving = false; + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + super.dispose(); + } + + String? _validateUrl(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Required.'; + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + return 'Enter a full URL, e.g. https://edge-01.local:8080'; + } + return null; + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _saving = true); + + final name = _nameController.text.trim(); + var url = _urlController.text.trim(); + if (url.endsWith('/')) url = url.substring(0, url.length - 1); + + final existing = widget.existing; + if (existing != null) { + await ref + .read(savedConnectionsProvider.notifier) + .update(existing.copyWith(name: name, baseUrl: url)); + } else { + final connection = Connection( + id: 'conn_${DateTime.now().microsecondsSinceEpoch}', + name: name, + baseUrl: url, + ); + await ref.read(savedConnectionsProvider.notifier).add(connection); + await ref.read(activeConnectionIdProvider.notifier).setActive(connection.id); + } + + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.existing == null ? 'Add connection' : 'Edit connection'), + content: Form( + key: _formKey, + child: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required.' : null, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 14), + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Base URL', + hintText: 'https://edge-01.local:8080', + ), + validator: _validateUrl, + keyboardType: TextInputType.url, + onFieldSubmitted: (_) => _save(), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/settings/widgets/connections_list.dart b/lib/features/settings/widgets/connections_list.dart new file mode 100644 index 0000000..14f1f68 --- /dev/null +++ b/lib/features/settings/widgets/connections_list.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/connections/connection.dart'; +import '../../../core/connections/connection_providers.dart'; +import '../../../core/theme/theme_x.dart'; +import 'connection_form_dialog.dart'; + +class ConnectionsList extends ConsumerWidget { + const ConnectionsList({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final connections = ref.watch(savedConnectionsProvider); + final activeId = ref.watch(activeConnectionIdProvider); + + if (connections.isEmpty) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No connections saved yet.', + style: context.text.bodyMedium, + ), + const SizedBox(height: 4), + Text( + "Add one to start managing a NodeMaster host.", + style: context.text.bodySmall, + ), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final connection in connections) + _ConnectionRow( + connection: connection, + active: connection.id == activeId, + ), + ], + ); + } +} + +class _ConnectionRow extends ConsumerWidget { + const _ConnectionRow({required this.connection, required this.active}); + + final Connection connection; + final bool active; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return InkWell( + onTap: active + ? null + : () => ref.read(activeConnectionIdProvider.notifier).setActive(connection.id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? context.status.good : context.colors.outline, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + active ? '${connection.name} · active' : connection.name, + style: context.text.labelLarge, + ), + Text(connection.baseUrl, style: context.dataStyles.dataMonoSmall), + ], + ), + ), + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => showConnectionFormDialog(context, existing: connection), + ), + IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => ref.read(savedConnectionsProvider.notifier).remove(connection.id), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/widgets/no_auth_callout.dart b/lib/features/settings/widgets/no_auth_callout.dart new file mode 100644 index 0000000..a2d3cb9 --- /dev/null +++ b/lib/features/settings/widgets/no_auth_callout.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/theme_x.dart'; + +/// Static warning — NodeMaster's REST API has no built-in authentication. +/// Anyone who can reach a node's port can start/stop services or trigger +/// backups on it. Not dismissable: this is a standing property of the +/// backend, not a one-time notice. +class NoAuthCallout extends StatelessWidget { + const NoAuthCallout({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Color.alphaBlend(context.status.warning.withValues(alpha: 0.10), context.colors.surface), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: context.status.warning.withValues(alpha: 0.4)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 18, color: context.status.warning), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: context.text.bodyMedium, + children: [ + TextSpan(text: 'No API authentication yet. ', style: const TextStyle(fontWeight: FontWeight.w700)), + const TextSpan( + text: "NodeMaster's REST API has no built-in auth — anyone who can reach a node's " + 'port can start, stop, or trigger backups on it. Until token auth ships, keep it ' + 'behind a VPN or a reverse proxy with its own access control, and treat saved ' + 'connection URLs as sensitive.', + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/node_identity_form.dart b/lib/features/settings/widgets/node_identity_form.dart new file mode 100644 index 0000000..fedb546 --- /dev/null +++ b/lib/features/settings/widgets/node_identity_form.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/theme/status_colors.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/status_pill.dart'; +import '../../../data/repositories/node_repository.dart'; +import '../../dashboard/providers/dashboard_summary_provider.dart'; + +/// Edits `NodeInfo.hostname`/`description`/`resticPassword` (`PUT /node/`). +/// Takes the already-loaded [node] rather than watching the provider +/// itself, so the text fields have a stable initial value to build +/// controllers from — the parent only renders this once +/// `currentNodeProvider` has data. +class NodeIdentityForm extends ConsumerStatefulWidget { + const NodeIdentityForm({super.key, required this.node}); + + final NodeInfo node; + + @override + ConsumerState createState() => _NodeIdentityFormState(); +} + +class _NodeIdentityFormState extends ConsumerState { + late final _hostnameController = TextEditingController(text: widget.node.hostname); + late final _descriptionController = TextEditingController(text: widget.node.description); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + bool _saving = false; + String? _error; + + @override + void dispose() { + _hostnameController.dispose(); + _descriptionController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) { + setState(() { + _saving = false; + _error = 'No active connection.'; + }); + return; + } + + final newPassword = _passwordController.text; + final draft = widget.node.copyWith( + hostname: _hostnameController.text.trim(), + description: _descriptionController.text.trim(), + resticPassword: newPassword.isEmpty ? null : newPassword, + ); + + try { + await repo.updateNode(draft); + // The password field is write-only — never echoed back by the API — + // so clear it locally once it's been sent rather than leaving the + // plaintext sitting in the form. + _passwordController.clear(); + ref.invalidate(currentNodeProvider); + ref.invalidate(dashboardSummaryProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved'))); + } + } catch (e) { + setState(() { + _error = e is ApiException ? e.userMessage : e.toString(); + }); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _hostnameController, + decoration: const InputDecoration(labelText: 'Hostname'), + ), + ), + const SizedBox(width: 14), + Expanded( + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration(labelText: 'Description'), + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: widget.node.resticPasswordSet ? 'Change restic password' : 'Set restic password', + hintText: widget.node.resticPasswordSet ? '••••••••' : 'Required to run restic backup targets', + suffixIcon: IconButton( + icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + ), + ), + ), + const SizedBox(width: 14), + Padding( + padding: const EdgeInsets.only(top: 16), + child: StatusPill( + label: widget.node.resticPasswordSet ? 'Password set' : 'Not configured', + severity: widget.node.resticPasswordSet ? Severity.good : Severity.warning, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Encrypts/decrypts every restic backup target on this node. Leave blank to keep the current ' + 'password — the API never sends it back, so this field always shows empty. There is no way to ' + 'unset it once configured.', + style: context.text.bodySmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 14), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save changes'), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/theme_toggle.dart b/lib/features/settings/widgets/theme_toggle.dart new file mode 100644 index 0000000..942a243 --- /dev/null +++ b/lib/features/settings/widgets/theme_toggle.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/theme_mode_provider.dart'; + +/// The full three-way Light/Dark/System control. The top bar's quick +/// sun/moon icon (`NavRailShell`) only ever toggles between explicit +/// light/dark — this is the one place "follow system" can be chosen. +class ThemeToggle extends ConsumerWidget { + const ThemeToggle({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeModeControllerProvider); + + return SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: ThemeMode.light, label: Text('Light'), icon: Icon(Icons.light_mode_outlined, size: 16)), + ButtonSegment(value: ThemeMode.dark, label: Text('Dark'), icon: Icon(Icons.dark_mode_outlined, size: 16)), + ButtonSegment( + value: ThemeMode.system, + label: Text('System'), + icon: Icon(Icons.brightness_auto_outlined, size: 16), + ), + ], + selected: {mode}, + onSelectionChanged: (selected) => + ref.read(themeModeControllerProvider.notifier).setThemeMode(selected.first), + ); + } +} diff --git a/lib/features/updates/updates_screen.dart b/lib/features/updates/updates_screen.dart new file mode 100644 index 0000000..fa099c5 --- /dev/null +++ b/lib/features/updates/updates_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models/models.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/theme/theme_x.dart'; +import '../../core/widgets/error_banner.dart'; +import '../../data/repositories/node_repository.dart'; +import 'widgets/updates_table.dart'; + +class UpdatesScreen extends ConsumerWidget { + const UpdatesScreen({super.key}); + + Future _delete(BuildContext context, WidgetRef ref, UpdateRecord record) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete update record?'), + content: const Text('This removes the record from NodeMaster\'s log. It does not undo the update.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: context.status.critical), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + final repo = ref.read(nodeRepositoryProvider); + if (repo == null) return; + try { + await repo.deleteUpdate(record.id); + ref.invalidate(updatesListProvider); + } catch (e) { + final message = e is ApiException ? e.userMessage : e.toString(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final updatesAsync = ref.watch(updatesListProvider); + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + updatesAsync.maybeWhen( + data: (records) => '${records.length} UPDATE RECORD(S)', + orElse: () => 'UPDATE HISTORY', + ), + style: context.text.titleSmall, + ), + const SizedBox(height: 12), + Expanded( + child: updatesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Align( + alignment: Alignment.topCenter, + child: ErrorBanner( + message: error is ApiException ? error.userMessage : error.toString(), + onRetry: () => ref.invalidate(updatesListProvider), + ), + ), + data: (records) => SingleChildScrollView( + child: UpdatesTable( + records: records, + onDelete: (record) => _delete(context, ref, record), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/updates/widgets/updates_table.dart b/lib/features/updates/widgets/updates_table.dart new file mode 100644 index 0000000..631c111 --- /dev/null +++ b/lib/features/updates/widgets/updates_table.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/models/models.dart'; +import '../../../core/models/severity_mapping.dart'; +import '../../../core/theme/theme_x.dart'; +import '../../../core/widgets/data_table_scaffold.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/mono_text.dart'; +import '../../../core/widgets/status_pill.dart'; + +class UpdatesTable extends StatelessWidget { + const UpdatesTable({super.key, required this.records, required this.onDelete}); + + final List records; + final void Function(UpdateRecord record) onDelete; + + @override + Widget build(BuildContext context) { + final sorted = [...records]..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final timeFormat = DateFormat('yyyy-MM-dd HH:mm'); + + return DataTableScaffold( + empty: const EmptyState( + icon: Icons.history_rounded, + title: 'No update history yet', + message: 'OS package update runs will show up here.', + ), + columns: const [ + DataColumn(label: Text('Timestamp')), + DataColumn(label: Text('Result')), + DataColumn(label: Text('Packages')), + DataColumn(label: Text('Message')), + DataColumn(label: Text('')), + ], + rows: [ + for (final record in sorted) + DataRow( + cells: [ + DataCell(MonoText(timeFormat.format(record.timestamp.toLocal()))), + DataCell( + StatusPill( + label: record.success ? 'success' : 'failed', + severity: severityForSuccess(record.success), + ), + ), + DataCell(_PackageChips(packages: record.packages)), + DataCell( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Text(record.message, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + DataCell( + IconButton( + tooltip: 'Delete', + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => onDelete(record), + ), + ), + ], + ), + ], + ); + } +} + +class _PackageChips extends StatelessWidget { + const _PackageChips({required this.packages}); + + final List packages; + + static const _maxShown = 2; + + @override + Widget build(BuildContext context) { + if (packages.isEmpty) { + return Text('—', style: context.text.bodySmall); + } + final shown = packages.take(_maxShown).toList(); + final remaining = packages.length - shown.length; + + return Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final package in shown) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text(package, style: context.dataStyles.dataMonoSmall), + ), + if (remaining > 0) + Text('+$remaining more', style: context.text.bodySmall), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6f1857a --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app.dart'; +import 'core/utils/shared_preferences_provider.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final prefs = await SharedPreferences.getInstance(); + + runApp( + ProviderScope( + // Riverpod 3's container-wide default auto-retries any failed + // provider a handful of times with exponential backoff. This app + // already has its own deliberate refresh model (manual pull + + // PollingMixin for the two screens that need a timer), so the + // blanket default just adds a second, invisible retry loop behind + // every failed request — surprising, and it visibly fights the + // explicit one on a persistently-unreachable connection. Disabled + // here; opt back in per-provider with `@Riverpod(retry: ...)` if a + // specific one ever wants it. + retry: (retryCount, error) => null, + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const App(), + ), + ); +} 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..cd6b26c --- /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 "manager") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.manager") + +# 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..2e1de87 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/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..f6a01c4 --- /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, "manager"); + 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, "manager"); + } + + 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..724bb2a --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6cac02c --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*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 */; }; +/* 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 /* manager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "manager.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 = ""; }; + 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 = ( + ); + 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 /* manager.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 = ( + 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; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* manager.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; + 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 = com.example.manager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/manager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/manager"; + }; + 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 = com.example.manager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/manager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/manager"; + }; + 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 = com.example.manager.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/manager.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/manager"; + }; + 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 */ + }; + 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..3a1699d --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..6d6cd3e --- /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 = manager + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.manager + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. 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..fc0e6e3 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,914 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + url: "https://pub.dev" + source: hosted + version: "8.4.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 + url: "https://pub.dev" + source: hosted + version: "0.1.11" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" + url: "https://pub.dev" + source: hosted + version: "0.13.10" + 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" + build: + dependency: transitive + description: + name: build + sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048" + url: "https://pub.dev" + source: hosted + version: "9.2.1" + 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" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: "direct main" + 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" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+8.4.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: "direct dev" + 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" + 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_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + 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" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + url: "https://pub.dev" + source: hosted + version: "6.11.2" + 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: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 + url: "https://pub.dev" + source: hosted + version: "5.6.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + 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" + 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" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: a0f68adb078b790faa3c655110a017f9a7b7b079a57bbd40f540e80dce5fcd29 + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.7" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: "7230014155777fc31ba3351bc2cb5a3b5717b11bfafe52b1553cb47d385f8897" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "49894543a42cf7a9954fc4e7366b6d3cb2e6ec0fa07775f660afcdd92d097702" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + 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: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + 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" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.1 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..da56684 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,100 @@ +name: manager +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.1 + +# 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 + + flutter_riverpod: ^3.0.0 + riverpod_annotation: ^3.0.3 + go_router: ^14.8.1 + dio: ^5.7.0 + freezed_annotation: ^3.0.0 + json_annotation: ^4.9.0 + shared_preferences: ^2.3.4 + intl: ^0.20.2 + collection: ^1.19.1 + +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 + + build_runner: ^2.4.13 + riverpod_generator: ^3.0.0 + freezed: ^3.0.0 + json_serializable: ^6.11.2 + mocktail: ^1.0.4 + fake_async: ^1.3.1 + +# 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 + + fonts: + # Variable fonts: the same file is listed once per weight so Skia + # renders the correct named instance from the font's wght axis + # instead of synthetically bolding a single static weight. + - family: Inter + fonts: + - asset: assets/fonts/Inter-Variable.ttf + weight: 400 + - asset: assets/fonts/Inter-Variable.ttf + weight: 500 + - asset: assets/fonts/Inter-Variable.ttf + weight: 600 + - asset: assets/fonts/Inter-Variable.ttf + weight: 700 + - asset: assets/fonts/Inter-Variable.ttf + weight: 800 + - family: JetBrains Mono + fonts: + - asset: assets/fonts/JetBrainsMono-Variable.ttf + weight: 400 + - asset: assets/fonts/JetBrainsMono-Variable.ttf + weight: 500 + - asset: assets/fonts/JetBrainsMono-Variable.ttf + weight: 600 diff --git a/test/core/models/service_test.dart b/test/core/models/service_test.dart new file mode 100644 index 0000000..5f01881 --- /dev/null +++ b/test/core/models/service_test.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/models/models.dart'; + +Map _loadFixture(String name) { + final raw = File('test/fixtures/$name').readAsStringSync(); + return jsonDecode(raw) as Map; +} + +void main() { + group('Service.fromJson', () { + test('parses a fully-populated service including nested backup config', () { + final service = Service.fromJson(_loadFixture('service_full.json')); + + expect(service.id, 'svc_123'); + expect(service.name, 'postgres'); + expect(service.composeFile, '/opt/compose/postgres/docker-compose.yml'); + expect(service.composeType, 'docker compose'); + expect(service.backupFolder, '/opt/data/containers/postgres'); + expect(service.status, 'up'); + expect(service.stopOnBackup, isTrue); + expect(service.externalBackupStopMinutes, 5); + expect(service.externalBackupStartMinutes, 2); + + final backup = service.backup; + expect(backup, isNotNull); + expect(backup!.sourcePath, '/opt/data/containers/postgres'); + expect(backup.nextRun, DateTime.parse('2026-07-27T02:00:00Z')); + expect(backup.lastRun, DateTime.parse('2026-07-26T02:00:00Z')); + + expect(backup.targets, hasLength(1)); + final target = backup.targets.single; + expect(target.id, 't1'); + expect(target.method, BackupMethod.restic); + expect(target.remote, 'sftp:nas-cold:/backups/edge-01'); + expect(target.params, {'transfers': '4'}); + expect(target.schedule, '0 2 * * *'); + expect(target.lastRun, DateTime.parse('2026-07-26T02:00:00Z')); + expect(target.nextRun, DateTime.parse('2026-07-27T02:00:00Z')); + + expect(backup.history, hasLength(1)); + final execution = backup.history.single; + expect(execution.success, isTrue); + expect(execution.durationSeconds, 12); + expect(execution.targetId, 't1'); + }); + + test('fills in defaults for every omitted/nullable field', () { + final service = Service.fromJson(_loadFixture('service_minimal.json')); + + expect(service.id, 'svc_456'); + expect(service.name, 'n8n'); + expect(service.status, 'down'); + expect(service.composeType, isNull); + expect(service.backupFolder, isNull); + expect(service.stopOnBackup, isFalse); + expect(service.externalBackupStopMinutes, 0); + expect(service.externalBackupStartMinutes, 0); + expect(service.backup, isNull); + }); + + test('round-trips through toJson/fromJson without losing data', () { + final original = Service.fromJson(_loadFixture('service_full.json')); + final roundTripped = Service.fromJson(original.toJson()); + expect(roundTripped, original); + }); + }); +} diff --git a/test/core/models/severity_mapping_test.dart b/test/core/models/severity_mapping_test.dart new file mode 100644 index 0000000..289cf10 --- /dev/null +++ b/test/core/models/severity_mapping_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/models/severity_mapping.dart'; +import 'package:manager/core/theme/status_colors.dart'; + +void main() { + group('severityForServiceStatus', () { + test('maps up/down to good/critical and anything else to warning', () { + expect(severityForServiceStatus('up'), Severity.good); + expect(severityForServiceStatus('down'), Severity.critical); + expect(severityForServiceStatus('unknown'), Severity.warning); + expect(severityForServiceStatus('anything-else'), Severity.warning); + }); + }); + + group('severityForSuccess', () { + test('maps true/false to good/critical', () { + expect(severityForSuccess(true), Severity.good); + expect(severityForSuccess(false), Severity.critical); + }); + }); + + group('severityForNodeReachable', () { + test('maps true/false to good/critical', () { + expect(severityForNodeReachable(true), Severity.good); + expect(severityForNodeReachable(false), Severity.critical); + }); + }); +} diff --git a/test/core/network/dio_error_mapper_test.dart b/test/core/network/dio_error_mapper_test.dart new file mode 100644 index 0000000..9336542 --- /dev/null +++ b/test/core/network/dio_error_mapper_test.dart @@ -0,0 +1,92 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/network/api_exception.dart'; +import 'package:manager/core/network/dio_error_mapper.dart'; + +RequestOptions _options() => RequestOptions(path: '/v1/services/'); + +void main() { + group('mapDioException', () { + test('maps timeout variants to ApiException.timeout', () { + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + ]) { + final result = mapDioException(DioException(requestOptions: _options(), type: type)); + expect(result, isA()); + } + }); + + test('maps connectionError to ApiException.network', () { + final result = mapDioException( + DioException( + requestOptions: _options(), + type: DioExceptionType.connectionError, + message: 'Failed host lookup', + ), + ); + expect(result, isA()); + expect((result as ApiNetworkException).message, contains('Failed host lookup')); + }); + + test('maps cancel to ApiException.cancelled', () { + final result = mapDioException( + DioException(requestOptions: _options(), type: DioExceptionType.cancel), + ); + expect(result, isA()); + }); + + test('maps badResponse with a {"error": msg} body to ApiException.server, extracting the message', () { + final response = Response( + requestOptions: _options(), + statusCode: 404, + data: {'error': 'not found'}, + ); + final result = mapDioException( + DioException(requestOptions: _options(), type: DioExceptionType.badResponse, response: response), + ); + expect(result, isA()); + final server = result as ApiServerException; + expect(server.statusCode, 404); + expect(server.message, 'not found'); + }); + + test('maps badResponse without a parseable body to the status message', () { + final response = Response( + requestOptions: _options(), + statusCode: 500, + statusMessage: 'Internal Server Error', + data: null, + ); + final result = mapDioException( + DioException(requestOptions: _options(), type: DioExceptionType.badResponse, response: response), + ); + expect(result, isA()); + expect((result as ApiServerException).message, 'Internal Server Error'); + }); + + test('passes an already-typed ApiException through unchanged', () { + const original = ApiException.timeout(); + expect(mapDioException(original), same(original)); + }); + + test('maps a non-Dio, non-ApiException error to ApiException.unknown', () { + final result = mapDioException(FormatException('bad json')); + expect(result, isA()); + }); + }); + + group('ApiExceptionUserMessage', () { + test('server(404) gets a generic "not found" message regardless of body text', () { + const exception = ApiException.server(404, 'service not found'); + expect(exception.userMessage, 'Not found.'); + }); + + test('server(non-404) includes the status code and body message', () { + const exception = ApiException.server(400, 'no backup targets configured'); + expect(exception.userMessage, contains('400')); + expect(exception.userMessage, contains('no backup targets configured')); + }); + }); +} diff --git a/test/core/widgets/data_table_scaffold_test.dart b/test/core/widgets/data_table_scaffold_test.dart new file mode 100644 index 0000000..28d9da1 --- /dev/null +++ b/test/core/widgets/data_table_scaffold_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/data_table_scaffold.dart'; +import 'package:manager/core/widgets/empty_state.dart'; + +void main() { + const columns = [DataColumn(label: Text('Name')), DataColumn(label: Text('Status'))]; + + testWidgets('renders the given rows', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: DataTableScaffold( + columns: columns, + rows: const [ + DataRow(cells: [DataCell(Text('postgres')), DataCell(Text('up'))]), + DataRow(cells: [DataCell(Text('grafana')), DataCell(Text('up'))]), + ], + ), + ), + ), + ); + + expect(find.text('postgres'), findsOneWidget); + expect(find.text('grafana'), findsOneWidget); + expect(find.byType(DataTable), findsOneWidget); + }); + + testWidgets('shows the empty-state fallback instead of an empty table', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: DataTableScaffold( + columns: columns, + rows: const [], + empty: const EmptyState(icon: Icons.layers_outlined, title: 'No services yet'), + ), + ), + ), + ); + + expect(find.text('No services yet'), findsOneWidget); + expect(find.byType(DataTable), findsNothing); + }); +} diff --git a/test/core/widgets/empty_state_test.dart b/test/core/widgets/empty_state_test.dart new file mode 100644 index 0000000..070f78a --- /dev/null +++ b/test/core/widgets/empty_state_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/empty_state.dart'; + +void main() { + testWidgets('renders title/message and invokes the action callback', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: EmptyState( + icon: Icons.layers_outlined, + title: 'No services yet', + message: 'Scan a compose folder to discover services.', + actionLabel: 'Scan now', + onAction: () => tapped = true, + ), + ), + ), + ); + + expect(find.text('No services yet'), findsOneWidget); + expect(find.text('Scan a compose folder to discover services.'), findsOneWidget); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Scan now')); + await tester.pump(); + expect(tapped, isTrue); + }); + + testWidgets('omits the action button when none is given', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: EmptyState(icon: Icons.inbox_outlined, title: 'Nothing here'), + ), + ), + ); + + expect(find.byType(OutlinedButton), findsNothing); + }); +} diff --git a/test/core/widgets/error_banner_test.dart b/test/core/widgets/error_banner_test.dart new file mode 100644 index 0000000..ea4edfc --- /dev/null +++ b/test/core/widgets/error_banner_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/error_banner.dart'; + +void main() { + testWidgets('renders the message and invokes onRetry', (tester) async { + var retried = false; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: ErrorBanner( + message: "Can't reach this node.", + onRetry: () => retried = true, + ), + ), + ), + ); + + expect(find.text("Can't reach this node."), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(retried, isTrue); + }); + + testWidgets('omits the retry button when onRetry is null', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold(body: ErrorBanner(message: 'Something broke.')), + ), + ); + + expect(find.text('Retry'), findsNothing); + }); +} diff --git a/test/core/widgets/mono_text_test.dart b/test/core/widgets/mono_text_test.dart new file mode 100644 index 0000000..a2d393c --- /dev/null +++ b/test/core/widgets/mono_text_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/mono_text.dart'; + +void main() { + testWidgets('renders in the JetBrains Mono family, small variant uses a smaller size', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: Column( + children: [ + MonoText('/opt/compose/postgres/docker-compose.yml'), + MonoText('svc_123', small: true), + ], + ), + ), + ), + ); + + final regular = tester.widget(find.text('/opt/compose/postgres/docker-compose.yml')); + final small = tester.widget(find.text('svc_123')); + + expect(regular.style?.fontFamily, 'JetBrains Mono'); + expect(small.style?.fontFamily, 'JetBrains Mono'); + expect(small.style!.fontSize!, lessThan(regular.style!.fontSize!)); + }); +} diff --git a/test/core/widgets/slide_over_panel_test.dart b/test/core/widgets/slide_over_panel_test.dart new file mode 100644 index 0000000..3a87ae7 --- /dev/null +++ b/test/core/widgets/slide_over_panel_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/slide_over_panel.dart'; + +void main() { + Widget wrap({required bool open, required VoidCallback onClose}) { + return MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Stack( + children: [ + const Center(child: Text('List content')), + SlideOverPanel( + open: open, + onClose: onClose, + title: 'vaultwarden', + body: const Text('Detail body'), + actions: [OutlinedButton(onPressed: () {}, child: const Text('Stop'))], + ), + ], + ), + ), + ); + } + + testWidgets('closed panel ignores taps on its (invisible) close button', (tester) async { + var closed = false; + await tester.pumpWidget(wrap(open: false, onClose: () => closed = true)); + await tester.pumpAndSettle(); + + // IgnorePointer(ignoring: true) means the close icon exists in the tree + // but isn't hit-testable — tapping it must not fire onClose. + await tester.tap(find.byIcon(Icons.close), warnIfMissed: false); + await tester.pump(); + expect(closed, isFalse); + }); + + testWidgets('open panel responds to the close button and the scrim tap', (tester) async { + var closed = false; + await tester.pumpWidget(wrap(open: true, onClose: () => closed = true)); + await tester.pumpAndSettle(); + + expect(find.text('vaultwarden'), findsOneWidget); + expect(find.text('Detail body'), findsOneWidget); + expect(find.text('Stop'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + expect(closed, isTrue); + }); +} diff --git a/test/core/widgets/sparkline_test.dart b/test/core/widgets/sparkline_test.dart new file mode 100644 index 0000000..53a222e --- /dev/null +++ b/test/core/widgets/sparkline_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/widgets/sparkline.dart'; + +void main() { + testWidgets('paints without error for multiple points, one point, and empty data', (tester) async { + for (final values in [ + [12.4, 16.0, 4.1, 19.8, 9.2], + [12.4], + [], + ]) { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: Center(child: Sparkline(values: values))), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(CustomPaint), findsWidgets); + } + }); +} diff --git a/test/core/widgets/stat_tile_test.dart b/test/core/widgets/stat_tile_test.dart new file mode 100644 index 0000000..1887915 --- /dev/null +++ b/test/core/widgets/stat_tile_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/theme/status_colors.dart'; +import 'package:manager/core/widgets/stat_tile.dart'; + +void main() { + testWidgets('renders label, value, qualifier and caption', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: StatTile( + label: 'Services', + value: '7', + valueQualifier: '/ 9 up', + caption: '1 down · 1 unknown', + severity: Severity.critical, + ), + ), + ), + ); + + expect(find.text('Services'), findsOneWidget); + expect(find.textContaining('7'), findsOneWidget); + expect(find.textContaining('/ 9 up'), findsOneWidget); + expect(find.text('1 down · 1 unknown'), findsOneWidget); + }); + + testWidgets('severity stripe color matches the resolved status color', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: StatTile(key: key, label: 'X', value: '1', severity: Severity.critical), + ), + ), + ); + + final context = key.currentContext!; + final statusColors = Theme.of(context).extension()!; + + final stripe = tester.widgetList(find.byType(Container)).firstWhere( + (c) => c.color == statusColors.critical, + orElse: () => throw StateError('No stripe container with critical color found'), + ); + expect(stripe.color, statusColors.critical); + }); +} diff --git a/test/core/widgets/status_pill_test.dart b/test/core/widgets/status_pill_test.dart new file mode 100644 index 0000000..32ad23c --- /dev/null +++ b/test/core/widgets/status_pill_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/core/theme/app_theme.dart'; +import 'package:manager/core/theme/status_colors.dart'; +import 'package:manager/core/widgets/status_pill.dart'; + +Widget _wrap(Widget child) => MaterialApp(theme: AppTheme.light(), home: Scaffold(body: Center(child: child))); + +void main() { + testWidgets('renders its label', (tester) async { + await tester.pumpWidget(_wrap(const StatusPill(label: 'up', severity: Severity.good))); + expect(find.text('up'), findsOneWidget); + }); + + testWidgets('good/critical/neutral each resolve to a distinct pill color', (tester) async { + Future pillColorFor(Severity severity) async { + await tester.pumpWidget(_wrap(StatusPill(label: severity.name, severity: severity))); + final container = tester.widget( + find.descendant(of: find.byType(StatusPill), matching: find.byType(Container)).first, + ); + final decoration = container.decoration as BoxDecoration; + return decoration.color!; + } + + final good = await pillColorFor(Severity.good); + final critical = await pillColorFor(Severity.critical); + final neutral = await pillColorFor(Severity.neutral); + + expect(good, isNot(equals(critical))); + expect(good, isNot(equals(neutral))); + expect(critical, isNot(equals(neutral))); + }); +} diff --git a/test/fixtures/service_full.json b/test/fixtures/service_full.json new file mode 100644 index 0000000..459faa0 --- /dev/null +++ b/test/fixtures/service_full.json @@ -0,0 +1,38 @@ +{ + "id": "svc_123", + "name": "postgres", + "compose_file": "/opt/compose/postgres/docker-compose.yml", + "compose_type": "docker compose", + "backup_folder": "/opt/data/containers/postgres", + "status": "up", + "stop_on_backup": true, + "external_backup_stop_minutes": 5, + "external_backup_start_minutes": 2, + "backup": { + "source_path": "/opt/data/containers/postgres", + "targets": [ + { + "id": "t1", + "name": "nas-cold", + "method": "restic", + "remote": "sftp:nas-cold:/backups/edge-01", + "params": { "transfers": "4" }, + "schedule": "0 2 * * *", + "last_run": "2026-07-26T02:00:00Z", + "next_run": "2026-07-27T02:00:00Z" + } + ], + "next_run": "2026-07-27T02:00:00Z", + "last_run": "2026-07-26T02:00:00Z", + "history": [ + { + "id": "bex_1", + "timestamp": "2026-07-26T02:00:00Z", + "success": true, + "duration_seconds": 12, + "message": "snapshot 4f2a9c1e created", + "target_id": "t1" + } + ] + } +} diff --git a/test/fixtures/service_minimal.json b/test/fixtures/service_minimal.json new file mode 100644 index 0000000..2ba6310 --- /dev/null +++ b/test/fixtures/service_minimal.json @@ -0,0 +1,6 @@ +{ + "id": "svc_456", + "name": "n8n", + "compose_file": "/opt/compose/n8n/docker-compose.yml", + "status": "down" +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..4f3450b --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:manager/app.dart'; +import 'package:manager/core/utils/shared_preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _pumpApp(WidgetTester tester, Map prefsValues) async { + // Wide enough that NavRailShell renders its expanded (non-narrow) rail, + // which is what shows the "NodeMaster" wordmark asserted on below. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + SharedPreferences.setMockInitialValues(prefsValues); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + // Matches main.dart: no container-wide auto-retry, so a deliberately + // unreachable test connection settles into AsyncError deterministically + // instead of retrying with backoff mid-test. + retry: (retryCount, error) => null, + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const App(), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('redirects to Settings when no connection is configured', (tester) async { + await _pumpApp(tester, {}); + + expect(find.text('NodeMaster'), findsOneWidget); + // The router redirect lands on Settings — its "Connections" block + // header (unique to that screen) proves we're actually there, not + // just that the nav rail lists the label. + expect(find.text('Connections'), findsOneWidget); + }); + + testWidgets('launches on the Dashboard when a connection is configured', (tester) async { + await _pumpApp(tester, { + // Port 1 refuses immediately rather than hanging until a timeout, + // so the test doesn't depend on real network access or reachability + // — only on routing/shell behavior once a connection exists. + 'connections': '[{"id":"conn_test","name":"Test","baseUrl":"http://127.0.0.1:1"}]', + 'active_connection_id': 'conn_test', + }); + + expect(find.text('NodeMaster'), findsOneWidget); + expect(find.text('Dashboard'), findsWidgets); + expect(find.text('Services'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + }); +} 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..69db1ab --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + manager + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..7cd1d06 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "manager", + "short_name": "manager", + "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..90b7800 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(manager 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 "manager") + +# 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..8b6d468 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} 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..b93c4c3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/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..59df082 --- /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", "com.example" "\0" + VALUE "FileDescription", "manager" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "manager" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "manager.exe" "\0" + VALUE "ProductName", "manager" "\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..609d5ea --- /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"manager", 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..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#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(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || 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_