Initial commit : Flutter app nymea energy monitor

- NymeaService : auth complète (Hello → Authenticate → SetNotificationStatus)
- Token top-level dans chaque requête JSON-RPC (fix critique GetThings)
- Persistance token via shared_preferences par hôte
- Dashboard : champs utilisateur/mot de passe dans le dialog de connexion
- ThingDetailScreen : renommer, réglages (settingsTypes) et supprimer
- NymeaThingClass : champ settingsTypes parsé depuis l'API
- NymeaThing : copyWith(name) + settingValue()
- Fix overflow _StateChip dans ThingsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
etm 2026-02-21 16:57:46 +01:00
commit d5dc0c7ca5
148 changed files with 27871 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# 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
# Claude Code
.claude/

45
.metadata Normal file
View File

@ -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: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: android
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: ios
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: linux
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: macos
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: web
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: windows
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
# 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'

95
CLAUDE.md Normal file
View File

@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Contexte général
Dossier parent de tous les projets : `/home/etm/Projects/`
- **`etm_powersync_app/`** (ce projet) — application Flutter HEMS, client mobile
- **`etm-nymea/`** — serveur nymea et plugins associés (C++/Qt), le backend
Ce projet est le **client Flutter** qui communique avec le serveur nymea via JSON-RPC 2.0.
---
## Commandes
```bash
flutter pub get # installer/mettre à jour les dépendances
flutter run # lancer sur device/émulateur connecté
flutter build apk # build Android release
flutter analyze # analyse statique Dart
flutter test # lancer les tests
```
## Architecture
### State management — un seul Provider
`NymeaService` (`lib/services/nymea_service.dart`) est le seul `ChangeNotifier`. Il est fourni à la racine dans `main.dart` via `ChangeNotifierProvider`. Tous les écrans le consomment avec `context.watch<NymeaService>()` ou `context.read<NymeaService>()`. Il n'y a aucune autre couche de state management.
### Navigation
`MainShell` dans `main.dart` utilise un `IndexedStack` de 5 écrans (l'état est préservé entre les onglets) :
| Index | Écran | Fichier |
|-------|-------|---------|
| 0 | Dashboard | `lib/screens/dashboard_screen.dart` |
| 1 | Énergie | `lib/screens/energy_screen.dart` |
| 2 | Things | `lib/screens/things_screen.dart` |
| 3 | A/C | `lib/screens/ac_screen.dart` |
| 4 | Favoris | `lib/screens/favorites_screen.dart` |
### Communication avec nymea
`NymeaService.connect()` supporte deux transports :
- **TCP brut** port 2222 (défaut) : nymea parle **en premier** (JSON délimité par `\n`). La fragmentation est gérée via `StringBuffer`.
- **WebSocket** port 4444 : c'est le **client qui parle en premier** en envoyant `JSONRPC.Hello`.
Les requêtes utilisent des IDs auto-incrémentés stockés dans `_pendingRequests: Map<int, Completer>`. Timeout global : 15 secondes. Les notifications push nymea (pas de champ `id`, ont un champ `notification`) sont dispatchées dans `_handleNotification()`.
**Convention nymea énergie importante** : `currentPowerProduction` est **négatif** (producteur = négatif). Le service convertit en positif dans `_parsePowerBalance()`.
### Mode simulation
`NymeaService.startSimulation()` déconnecte toute connexion live, active `_isSimulation = true`, génère des things fictifs et une production PV simulée en sinus (timer 2 s). Le dashboard démarre automatiquement la simulation au premier affichage si non connecté. Toutes les méthodes du service font un garde `if (_isSimulation) return …` pour éviter les vrais appels RPC.
### Modèles de données
- **`EnergyData`** (`lib/models/energy_data.dart`) — objet immutable avec `copyWith`, regroupe toutes les métriques énergie temps-réel et journalières + état de la borne EV.
- **`NymeaThing` / `NymeaThingClass` / `NymeaStateType` / `NymeaActionType`** (`lib/models/nymea_models.dart`) — miroir de l'API Integrations nymea. `NymeaThingClass.primaryStateType` détermine l'état affiché sur les cards.
- **`ThingCategory` + `interfaceToCategoryMap`** (`lib/models/thing_category.dart`) — mappe les interfaces nymea (ex. `"evcharger"`, `"solarinverter"`) vers des catégories d'affichage utilisées par `ThingsScreen`.
- **`FavoriteWidget`** (`lib/models/nymea_models.dart`) — descripteurs de widgets stockés en mémoire dans `NymeaService._favoriteWidgets`.
### Thème
`AppTheme` (`lib/theme/app_theme.dart`) expose des constantes `Color` nommées : `primaryGreen`, `solarYellow`, `gridGray`, `homeBlue`, `batteryGreen`, `boostRed`, `pvGreen`, `minPvBlue`, etc. Toujours utiliser ces constantes plutôt que des valeurs hex brutes.
## Conventions
- **Code** : anglais — **Commentaires** : français
- **Nommage** : camelCase variables, PascalCase classes
- Les écrans utilisent `Consumer<NymeaService>` ou `context.watch` — ne jamais dupliquer l'état du service dans un `State`
- **Unités** : Watts (W) pour les puissances instantanées, Wh pour les énergies journalières. L'API nymea retourne des totaux en kWh que le service multiplie par 1000.
## Méthodes nymea principales
| Méthode | Rôle |
|---------|------|
| `JSONRPC.Hello` | Handshake (WebSocket uniquement — envoyé par le client) |
| `Integrations.GetThings` | Charger les devices configurés |
| `Integrations.GetThingClasses` | Charger les définitions de classes (filtre `thingClassIds` supporté) |
| `Integrations.ExecuteAction` | Exécuter une action sur un thing |
| `Integrations.GetStateValue` | Lire une valeur d'état |
| `Energy.GetPowerBalance` | Snapshot du flux énergétique actuel |
| `Energy.GetEnergyLogs` | Historique énergie (échantillons horaires) |
| `Energy.SetChargingMode` | Mode borne EV : `pv`, `minpv`, `boost` |
Notifications push gérées : `Energy.PowerBalanceChanged`, `Energy.RootMeterChanged`, `Integrations.StateChanged`, `Integrations.ThingAdded`, `Integrations.ThingRemoved`.
## Documentation API
- `docs/nymea_api.md` — introspection complète de l'API nymea
- `docs/nymea_integrations.md` — namespace Integrations en détail

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# etm_powersync_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
analysis_options.yaml Normal file
View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.etm_powersync_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.etm_powersync_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package com.example.etm_powersync_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

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

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

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

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

5938
docs/nymea_api.md Normal file

File diff suppressed because it is too large Load Diff

10892
docs/nymea_integrations.md Normal file

File diff suppressed because it is too large Load Diff

34
ios/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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.etmPowersyncApp;
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.etmPowersyncApp.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.etmPowersyncApp.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.etmPowersyncApp.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.etmPowersyncApp;
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.etmPowersyncApp;
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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

70
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Etm Powersync App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>etm_powersync_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

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

168
lib/main.dart Normal file
View File

@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'services/nymea_service.dart';
import 'screens/dashboard_screen.dart';
import 'screens/energy_screen.dart';
import 'screens/things_screen.dart';
import 'screens/ac_screen.dart';
import 'screens/favorites_screen.dart';
import 'theme/app_theme.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
runApp(
ChangeNotifierProvider(
create: (_) => NymeaService(),
child: const NymeaEnergyApp(),
),
);
}
class NymeaEnergyApp extends StatelessWidget {
const NymeaEnergyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Nymea Energy',
debugShowCheckedModeBanner: false,
theme: AppTheme.theme,
home: const MainShell(),
);
}
}
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
final List<Widget> _screens = const [
DashboardScreen(),
EnergyScreen(),
ThingsScreen(),
ACScreen(),
FavoritesScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _screens,
),
bottomNavigationBar: _BottomNav(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
),
);
}
}
class _BottomNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const _BottomNav({required this.currentIndex, required this.onTap});
@override
Widget build(BuildContext context) {
final items = [
_NavItem(icon: Icons.home_rounded, label: 'Dashboard'),
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie'),
_NavItem(icon: Icons.device_hub_rounded, label: 'Things'),
_NavItem(icon: Icons.ac_unit_rounded, label: 'A/C'),
_NavItem(icon: Icons.star_rounded, label: 'Favoris'),
];
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha:0.08),
blurRadius: 12,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
height: 62,
child: Row(
children: items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
final isSelected = currentIndex == i;
return Expanded(
child: GestureDetector(
onTap: () => onTap(i),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? AppTheme.primaryGreen.withValues(alpha:0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
item.icon,
color: isSelected
? AppTheme.primaryGreen
: AppTheme.textLight,
size: 22,
),
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
color: isSelected
? AppTheme.primaryGreen
: AppTheme.textLight,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
),
),
);
}
}
class _NavItem {
final IconData icon;
final String label;
const _NavItem({required this.icon, required this.label});
}

101
lib/models/energy_data.dart Normal file
View File

@ -0,0 +1,101 @@
class EnergyData {
final double pvPower; // Watts - production solaire
final double homePower; // Watts - consommation maison
final double batteryPower; // Watts - puissance batterie (+ charge, - décharge)
final double gridPower; // Watts - réseau (+ injection, - soutif)
final double batterySOC; // % - état de charge batterie
final double temperature; // °C
final String weatherIcon;
// Journée
final double dayProductionWh;
final double daySelfConsumptionWh;
final double dayGridInjectionWh;
final double selfConsumptionRate; // %
final double autonomyRate; // %
final double dayGains; //
// Borne de recharge
final ChargingMode chargingMode;
final double chargingPower; // kW
final double solarSourcePercent; // %
final bool gridLimitOk;
const EnergyData({
this.pvPower = 0,
this.homePower = 0,
this.batteryPower = 0,
this.gridPower = 0,
this.batterySOC = 0,
this.temperature = 0,
this.weatherIcon = 'cloud_snow',
this.dayProductionWh = 0,
this.daySelfConsumptionWh = 0,
this.dayGridInjectionWh = 0,
this.selfConsumptionRate = 0,
this.autonomyRate = 0,
this.dayGains = 0,
this.chargingMode = ChargingMode.pv,
this.chargingPower = 3.6,
this.solarSourcePercent = 82,
this.gridLimitOk = true,
});
EnergyData copyWith({
double? pvPower,
double? homePower,
double? batteryPower,
double? gridPower,
double? batterySOC,
double? temperature,
String? weatherIcon,
double? dayProductionWh,
double? daySelfConsumptionWh,
double? dayGridInjectionWh,
double? selfConsumptionRate,
double? autonomyRate,
double? dayGains,
ChargingMode? chargingMode,
double? chargingPower,
double? solarSourcePercent,
bool? gridLimitOk,
}) {
return EnergyData(
pvPower: pvPower ?? this.pvPower,
homePower: homePower ?? this.homePower,
batteryPower: batteryPower ?? this.batteryPower,
gridPower: gridPower ?? this.gridPower,
batterySOC: batterySOC ?? this.batterySOC,
temperature: temperature ?? this.temperature,
weatherIcon: weatherIcon ?? this.weatherIcon,
dayProductionWh: dayProductionWh ?? this.dayProductionWh,
daySelfConsumptionWh: daySelfConsumptionWh ?? this.daySelfConsumptionWh,
dayGridInjectionWh: dayGridInjectionWh ?? this.dayGridInjectionWh,
selfConsumptionRate: selfConsumptionRate ?? this.selfConsumptionRate,
autonomyRate: autonomyRate ?? this.autonomyRate,
dayGains: dayGains ?? this.dayGains,
chargingMode: chargingMode ?? this.chargingMode,
chargingPower: chargingPower ?? this.chargingPower,
solarSourcePercent: solarSourcePercent ?? this.solarSourcePercent,
gridLimitOk: gridLimitOk ?? this.gridLimitOk,
);
}
}
enum ChargingMode { pv, minPv, boost }
class HistoryPoint {
final DateTime time;
final double pvWh;
final double homeWh;
final double gridWh;
final double batteryWh;
const HistoryPoint({
required this.time,
this.pvWh = 0,
this.homeWh = 0,
this.gridWh = 0,
this.batteryWh = 0,
});
}

View File

@ -0,0 +1,247 @@
// Thing models
class NymeaThing {
final String id;
final String name;
final String thingClassId;
final String setupStatus;
final List<Map<String, dynamic>> paramValues;
final List<NymeaStateValue> states;
const NymeaThing({
required this.id,
required this.name,
required this.thingClassId,
required this.setupStatus,
required this.paramValues,
this.states = const [],
});
factory NymeaThing.fromJson(Map<String, dynamic> j) {
return NymeaThing(
id: j['id'] as String? ?? '',
name: j['name'] as String? ?? 'Unknown',
thingClassId: j['thingClassId'] as String? ?? '',
setupStatus: j['setupStatus'] as String? ?? '',
paramValues: List<Map<String, dynamic>>.from(j['paramValues'] ?? []),
states: (j['states'] as List? ?? [])
.map((s) => NymeaStateValue.fromJson(s))
.toList(),
);
}
NymeaThing copyWith({String? name, List<NymeaStateValue>? states}) => NymeaThing(
id: id,
name: name ?? this.name,
thingClassId: thingClassId,
setupStatus: setupStatus,
paramValues: paramValues,
states: states ?? this.states,
);
dynamic settingValue(String settingTypeId) {
try {
return paramValues
.firstWhere((p) => p['paramTypeId'] == settingTypeId)['value'];
} catch (_) {
return null;
}
}
dynamic stateValue(String stateTypeId) {
try {
return states.firstWhere((s) => s.stateTypeId == stateTypeId).value;
} catch (_) {
return null;
}
}
bool get isSetupComplete => setupStatus == 'ThingSetupStatusComplete';
}
class NymeaStateValue {
final String stateTypeId;
final dynamic value;
const NymeaStateValue({required this.stateTypeId, required this.value});
factory NymeaStateValue.fromJson(Map<String, dynamic> j) => NymeaStateValue(
stateTypeId: j['stateTypeId'] as String? ?? '',
value: j['value'],
);
}
// State type definition (from GetThingClasses)
class NymeaStateType {
final String id;
final String name;
final String displayName;
final String type; // "Double", "Bool", "Int", "String"
final String unit; // "W", "V", "A", "Percentage", ""
final dynamic defaultValue;
final bool writable;
const NymeaStateType({
required this.id,
required this.name,
required this.displayName,
required this.type,
required this.unit,
this.defaultValue,
this.writable = false,
});
factory NymeaStateType.fromJson(Map<String, dynamic> j) => NymeaStateType(
id: j['id'] as String? ?? '',
name: j['name'] as String? ?? '',
displayName: j['displayName'] as String? ?? j['name'] as String? ?? '',
type: j['type'] as String? ?? 'String',
unit: j['unit'] as String? ?? '',
defaultValue: j['defaultValue'],
writable: j['writable'] as bool? ?? false,
);
String formatValue(dynamic value) {
if (value == null) return '';
switch (unit) {
case 'W': return '${_fmt(value)} W';
case 'kW': return '${_fmt(value)} kW';
case 'Wh': return '${_fmt(value)} Wh';
case 'kWh': return '${_fmt(value)} kWh';
case 'V': return '${_fmt(value)} V';
case 'A': return '${_fmt(value)} A';
case 'Percentage': return '${_fmt(value)} %';
case 'Celsius': return '${_fmt(value)} °C';
case 'Hz': return '${_fmt(value)} Hz';
case 'VoltAmpereReactive': return '${_fmt(value)} VAr';
case 'KiloVoltAmpereHour': return '${_fmt(value)} kVAh';
case 'Rpm': return '${_fmt(value)} rpm';
default:
if (type == 'Bool') return (value == true || value == 'true') ? 'Oui' : 'Non';
return value.toString();
}
}
String _fmt(dynamic value) {
if (value is double) {
if (value.abs() >= 1000) return value.toStringAsFixed(0);
if (value.abs() >= 10) return value.toStringAsFixed(1);
return value.toStringAsFixed(2);
}
return value.toString();
}
}
// Action type definition
class NymeaActionType {
final String id;
final String name;
final String displayName;
final List<Map<String, dynamic>> paramTypes;
const NymeaActionType({
required this.id,
required this.name,
required this.displayName,
this.paramTypes = const [],
});
factory NymeaActionType.fromJson(Map<String, dynamic> j) => NymeaActionType(
id: j['id'] as String? ?? '',
name: j['name'] as String? ?? '',
displayName: j['displayName'] as String? ?? j['name'] as String? ?? '',
paramTypes: List<Map<String, dynamic>>.from(j['paramTypes'] ?? []),
);
}
class NymeaThingClass {
final String id;
final String name;
final String displayName;
final List<String> interfaces; // clé pour la catégorisation
final List<NymeaStateType> stateTypes;
final List<NymeaActionType> actionTypes;
final List<Map<String, dynamic>> paramTypes;
final List<NymeaStateType> settingsTypes;
const NymeaThingClass({
required this.id,
required this.name,
required this.displayName,
this.interfaces = const [],
this.stateTypes = const [],
this.actionTypes = const [],
this.paramTypes = const [],
this.settingsTypes = const [],
});
factory NymeaThingClass.fromJson(Map<String, dynamic> j) => NymeaThingClass(
id: j['id'] as String? ?? '',
name: j['name'] as String? ?? '',
displayName: j['displayName'] as String? ?? '',
interfaces: List<String>.from(j['interfaces'] ?? []),
stateTypes: (j['stateTypes'] as List? ?? [])
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
.toList(),
actionTypes: (j['actionTypes'] as List? ?? [])
.map((a) => NymeaActionType.fromJson(a as Map<String, dynamic>))
.toList(),
paramTypes: List<Map<String, dynamic>>.from(j['paramTypes'] ?? []),
settingsTypes: (j['settingsTypes'] as List? ?? [])
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
.toList(),
);
/// Retourne la stateType par son id
NymeaStateType? stateTypeById(String id) {
try { return stateTypes.firstWhere((s) => s.id == id); }
catch (_) { return null; }
}
/// Retourne la stateType "principale" à afficher sur la card
NymeaStateType? get primaryStateType {
// Priorité : power energy temperature connected on/off premier
const priority = [
'currentPower', 'power', 'totalEnergyConsumed', 'totalEnergyProduced',
'temperature', 'batterylevel', 'soc', 'connected', 'on',
];
for (final p in priority) {
final found = stateTypes.where(
(s) => s.name.toLowerCase() == p.toLowerCase()
);
if (found.isNotEmpty) return found.first;
}
return stateTypes.isNotEmpty ? stateTypes.first : null;
}
}
// Favorite Widgets
enum FavoriteType {
pvPower,
homePower,
batterySOC,
gridPower,
rates,
evCharger,
thingState,
chart,
}
class FavoriteWidget {
final String id;
final FavoriteType type;
final String title;
final String? thingId;
final String? stateTypeId;
final Map<String, dynamic> extra;
const FavoriteWidget({
required this.id,
required this.type,
required this.title,
this.thingId,
this.stateTypeId,
this.extra = const {},
});
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
import 'nymea_models.dart';
//
// Catégories affichées dans le Things screen
//
enum ThingCategory {
energy, // smartmeter, energymeter, powersocket
solar, // solarinverter, solarpanel
battery, // battery, energystorage
evCharger, // evcharger
hvac, // thermostat, heatingzone, ac, pump
lighting, // light, dimmablelight, colorlight
sensors, // sensor, temperaturesensor, humiditysensor, motionsensor
network, // networkdevice, gateway
notifications, // pushnotification, email, sms
media, // mediadevice, mediaplayer
weather, // weather
other, // tout le reste
}
class ThingCategoryInfo {
final ThingCategory category;
final String label;
final IconData icon;
final Color color;
const ThingCategoryInfo({
required this.category,
required this.label,
required this.icon,
required this.color,
});
}
//
// Mapping interface nymea catégorie
//
const Map<String, ThingCategory> interfaceToCategoryMap = {
'smartmeter': ThingCategory.energy,
'electricmeter': ThingCategory.energy,
'energymeter': ThingCategory.energy,
'smartenergymeter': ThingCategory.energy,
'extendedsmartmeter': ThingCategory.energy,
'powersocket': ThingCategory.energy,
'smartplug': ThingCategory.energy,
'solarinverter': ThingCategory.solar,
'solarpanel': ThingCategory.solar,
'inverter': ThingCategory.solar,
'battery': ThingCategory.battery,
'energystorage': ThingCategory.battery,
'batterymonitor': ThingCategory.battery,
'evcharger': ThingCategory.evCharger,
'wallbox': ThingCategory.evCharger,
'thermostat': ThingCategory.hvac,
'heatingzone': ThingCategory.hvac,
'ac': ThingCategory.hvac,
'airconditioner': ThingCategory.hvac,
'pump': ThingCategory.hvac,
'heatpump': ThingCategory.hvac,
'ventilation': ThingCategory.hvac,
'boiler': ThingCategory.hvac,
'light': ThingCategory.lighting,
'dimmablelight': ThingCategory.lighting,
'colorlight': ThingCategory.lighting,
'colortemperaturelight': ThingCategory.lighting,
'sensor': ThingCategory.sensors,
'temperaturesensor': ThingCategory.sensors,
'humiditysensor': ThingCategory.sensors,
'pressuresensor': ThingCategory.sensors,
'motionsensor': ThingCategory.sensors,
'doorsensor': ThingCategory.sensors,
'windowsensor': ThingCategory.sensors,
'smokesensor': ThingCategory.sensors,
'co2sensor': ThingCategory.sensors,
'lightsensor': ThingCategory.sensors,
'networkdevice': ThingCategory.network,
'gateway': ThingCategory.network,
'router': ThingCategory.network,
'pushnotification': ThingCategory.notifications,
'notificationservice': ThingCategory.notifications,
'notification': ThingCategory.notifications,
'email': ThingCategory.notifications,
'sms': ThingCategory.notifications,
'weather': ThingCategory.weather,
'weatherstation': ThingCategory.weather,
'mediadevice': ThingCategory.media,
'mediaplayer': ThingCategory.media,
};
//
// Infos visuelles par catégorie
//
const Map<ThingCategory, ThingCategoryInfo> categoryInfoMap = {
ThingCategory.energy: ThingCategoryInfo(
category: ThingCategory.energy,
label: 'Compteurs & Prises',
icon: Icons.electrical_services_rounded,
color: AppTheme.gridGray,
),
ThingCategory.solar: ThingCategoryInfo(
category: ThingCategory.solar,
label: 'Solaire',
icon: Icons.solar_power_rounded,
color: AppTheme.solarYellow,
),
ThingCategory.battery: ThingCategoryInfo(
category: ThingCategory.battery,
label: 'Stockage',
icon: Icons.battery_charging_full_rounded,
color: AppTheme.batteryGreen,
),
ThingCategory.evCharger: ThingCategoryInfo(
category: ThingCategory.evCharger,
label: 'Chargeurs EV',
icon: Icons.electric_car_rounded,
color: AppTheme.minPvBlue,
),
ThingCategory.hvac: ThingCategoryInfo(
category: ThingCategory.hvac,
label: 'Chauffage & Climatisation',
icon: Icons.thermostat_rounded,
color: Color(0xFFFF7043),
),
ThingCategory.lighting: ThingCategoryInfo(
category: ThingCategory.lighting,
label: 'Éclairage',
icon: Icons.lightbulb_rounded,
color: Color(0xFFFFCA28),
),
ThingCategory.sensors: ThingCategoryInfo(
category: ThingCategory.sensors,
label: 'Capteurs',
icon: Icons.sensors_rounded,
color: Color(0xFF26C6DA),
),
ThingCategory.network: ThingCategoryInfo(
category: ThingCategory.network,
label: 'Réseau & Passerelles',
icon: Icons.router_rounded,
color: Color(0xFF7C4DFF),
),
ThingCategory.notifications: ThingCategoryInfo(
category: ThingCategory.notifications,
label: 'Services de notification',
icon: Icons.notifications_rounded,
color: Color(0xFFEC407A),
),
ThingCategory.weather: ThingCategoryInfo(
category: ThingCategory.weather,
label: 'Météo',
icon: Icons.wb_cloudy_rounded,
color: Color(0xFF42A5F5),
),
ThingCategory.media: ThingCategoryInfo(
category: ThingCategory.media,
label: 'Multimédia',
icon: Icons.speaker_rounded,
color: Color(0xFFAB47BC),
),
ThingCategory.other: ThingCategoryInfo(
category: ThingCategory.other,
label: 'Autres',
icon: Icons.device_hub_rounded,
color: Color(0xFF78909C),
),
};
//
// Extension utilitaire sur NymeaThingClass
//
extension ThingClassCategoryExt on NymeaThingClass {
ThingCategory get category {
for (final iface in interfaces) {
final cat = interfaceToCategoryMap[iface.toLowerCase()];
if (cat != null) return cat;
}
return ThingCategory.other;
}
ThingCategoryInfo get categoryDetails => categoryInfoMap[category]!;
}

333
lib/screens/ac_screen.dart Normal file
View File

@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class ACScreen extends StatefulWidget {
const ACScreen({super.key});
@override
State<ACScreen> createState() => _ACScreenState();
}
class _ACScreenState extends State<ACScreen> {
final List<_ACZone> _zones = [
_ACZone(name: 'Salon', currentTemp: 19.5, targetTemp: 21, mode: ACMode.heat, isOn: true),
_ACZone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19, mode: ACMode.cool, isOn: false),
_ACZone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22, mode: ACMode.heat, isOn: true),
_ACZone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21, mode: ACMode.auto, isOn: false),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: const Text(
'Climatisation / Chauffage',
style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.textDark),
),
actions: [
IconButton(
icon: const Icon(Icons.schedule, color: AppTheme.textDark),
onPressed: () {},
),
],
),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _zones.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
return _ZoneCard(
zone: _zones[index],
onChanged: (updated) {
setState(() => _zones[index] = updated);
},
);
},
),
);
}
}
enum ACMode { heat, cool, auto, fan }
class _ACZone {
final String name;
final double currentTemp;
final double targetTemp;
final ACMode mode;
final bool isOn;
const _ACZone({
required this.name,
required this.currentTemp,
required this.targetTemp,
required this.mode,
required this.isOn,
});
_ACZone copyWith({
double? targetTemp,
ACMode? mode,
bool? isOn,
}) =>
_ACZone(
name: name,
currentTemp: currentTemp,
targetTemp: targetTemp ?? this.targetTemp,
mode: mode ?? this.mode,
isOn: isOn ?? this.isOn,
);
}
class _ZoneCard extends StatelessWidget {
final _ACZone zone;
final ValueChanged<_ACZone> onChanged;
const _ZoneCard({required this.zone, required this.onChanged});
Color get _modeColor {
switch (zone.mode) {
case ACMode.heat:
return Colors.orange;
case ACMode.cool:
return Colors.cyan;
case ACMode.auto:
return AppTheme.primaryGreen;
case ACMode.fan:
return Colors.blueGrey;
}
}
IconData get _modeIcon {
switch (zone.mode) {
case ACMode.heat:
return Icons.whatshot_rounded;
case ACMode.cool:
return Icons.ac_unit_rounded;
case ACMode.auto:
return Icons.autorenew_rounded;
case ACMode.fan:
return Icons.air_rounded;
}
}
String get _modeLabel {
switch (zone.mode) {
case ACMode.heat:
return 'Chauffage';
case ACMode.cool:
return 'Clim';
case ACMode.auto:
return 'Auto';
case ACMode.fan:
return 'Ventilation';
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Header row
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: zone.isOn
? _modeColor.withValues(alpha:0.15)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_modeIcon,
color: zone.isOn ? _modeColor : Colors.grey,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zone.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 16)),
Text(
zone.isOn ? _modeLabel : 'Éteint',
style: TextStyle(
fontSize: 12,
color:
zone.isOn ? _modeColor : AppTheme.textLight),
),
],
),
),
Switch(
value: zone.isOn,
onChanged: (v) => onChanged(zone.copyWith(isOn: v)),
activeThumbColor: _modeColor,
),
],
),
if (zone.isOn) ...[
const Divider(height: 20),
// Temperature
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
const Text('Actuelle',
style: TextStyle(
fontSize: 12, color: AppTheme.textLight)),
Text(
'${zone.currentTemp.toStringAsFixed(1)}°C',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
],
),
Icon(Icons.arrow_forward_rounded,
color: Colors.grey.shade400),
Column(
children: [
const Text('Cible',
style: TextStyle(
fontSize: 12, color: AppTheme.textLight)),
Row(
children: [
IconButton(
icon: Icon(Icons.remove_circle_outline,
color: _modeColor),
onPressed: () => onChanged(zone.copyWith(
targetTemp: zone.targetTemp - 0.5)),
),
Text(
'${zone.targetTemp.toStringAsFixed(1)}°C',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: _modeColor,
),
),
IconButton(
icon: Icon(Icons.add_circle_outline,
color: _modeColor),
onPressed: () => onChanged(zone.copyWith(
targetTemp: zone.targetTemp + 0.5)),
),
],
),
],
),
],
),
// Mode selector
const SizedBox(height: 8),
Row(
children: ACMode.values.map((m) {
final selected = zone.mode == m;
final color = _modeColorFor(m);
final icon = _modeIconFor(m);
final label = _modeLabelFor(m);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: () => onChanged(zone.copyWith(mode: m)),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: selected
? color.withValues(alpha:0.15)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: selected
? Border.all(color: color, width: 1.5)
: null,
),
child: Column(
children: [
Icon(icon,
size: 18,
color: selected ? color : Colors.grey),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
fontSize: 10,
color:
selected ? color : AppTheme.textLight,
fontWeight: selected
? FontWeight.bold
: FontWeight.normal)),
],
),
),
),
),
);
}).toList(),
),
],
],
),
),
);
}
Color _modeColorFor(ACMode m) {
switch (m) {
case ACMode.heat:
return Colors.orange;
case ACMode.cool:
return Colors.cyan;
case ACMode.auto:
return AppTheme.primaryGreen;
case ACMode.fan:
return Colors.blueGrey;
}
}
IconData _modeIconFor(ACMode m) {
switch (m) {
case ACMode.heat:
return Icons.whatshot_rounded;
case ACMode.cool:
return Icons.ac_unit_rounded;
case ACMode.auto:
return Icons.autorenew_rounded;
case ACMode.fan:
return Icons.air_rounded;
}
}
String _modeLabelFor(ACMode m) {
switch (m) {
case ACMode.heat:
return 'Chauf.';
case ACMode.cool:
return 'Clim';
case ACMode.auto:
return 'Auto';
case ACMode.fan:
return 'Vent.';
}
}
}

View File

@ -0,0 +1,556 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
import '../widgets/energy_flow_widget.dart';
import '../widgets/production_card.dart';
import '../widgets/ev_charging_card.dart';
import '../widgets/gains_card.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
// Start simulation on first load
WidgetsBinding.instance.addPostFrameCallback((_) {
final service = context.read<NymeaService>();
if (!service.connected) {
service.startSimulation();
}
});
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final data = service.energyData;
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
body: RefreshIndicator(
onRefresh: () async {
service.startSimulation();
},
color: AppTheme.primaryGreen,
child: CustomScrollView(
slivers: [
// App bar
SliverAppBar(
floating: true,
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppTheme.primaryGreen,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.bolt, color: Colors.white, size: 22),
),
const SizedBox(width: 10),
const Text(
'Nymea Energy',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 18,
),
),
],
),
actions: [
// Connection status
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Icon(
service.connected
? Icons.wifi_rounded
: Icons.wifi_off_rounded,
color: service.connected
? AppTheme.primaryGreen
: Colors.red,
),
onPressed: () => _showConnectionDialog(context, service),
),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: IconButton(
icon: const Icon(Icons.notifications_outlined,
color: AppTheme.textDark),
onPressed: () {},
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Energy flow
EnergyFlowWidget(data: data),
const SizedBox(height: 12),
// Self-consumption rates
_RatesRow(data: data),
const SizedBox(height: 12),
// Production card
ProductionCard(data: data),
const SizedBox(height: 12),
// Gains
GainsCard(data: data),
const SizedBox(height: 12),
// EV Charging
EVChargingCard(data: data, service: service),
const SizedBox(height: 16),
]),
),
),
],
),
),
);
},
);
}
void _showConnectionDialog(BuildContext context, NymeaService service) {
showDialog(
context: context,
builder: (ctx) => _ConnectionDialog(service: service),
);
}
}
class _RatesRow extends StatelessWidget {
final dynamic data;
const _RatesRow({required this.data});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _RateCard(
label: 'Autoconsommation',
value: data.selfConsumptionRate,
icon: Icons.wb_sunny_rounded,
color: AppTheme.solarYellow,
),
),
const SizedBox(width: 12),
Expanded(
child: _RateCard(
label: 'Autonomie',
value: data.autonomyRate,
icon: Icons.home_rounded,
color: AppTheme.homeBlue,
),
),
],
);
}
}
class _RateCard extends StatelessWidget {
final String label;
final double value;
final IconData icon;
final Color color;
const _RateCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
'${value.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (value / 100).clamp(0, 1),
backgroundColor: color.withValues(alpha:0.15),
valueColor: AlwaysStoppedAnimation(color),
minHeight: 6,
),
),
],
),
),
);
}
}
//
// Dialog de connexion avec sélection du protocole TCP / WebSocket
//
class _ConnectionDialog extends StatefulWidget {
final NymeaService service;
const _ConnectionDialog({required this.service});
@override
State<_ConnectionDialog> createState() => _ConnectionDialogState();
}
class _ConnectionDialogState extends State<_ConnectionDialog> {
late TextEditingController _hostCtrl;
late TextEditingController _portCtrl;
late TextEditingController _userCtrl;
late TextEditingController _passCtrl;
late NymeaProtocol _protocol;
bool _connecting = false;
bool _verbose = false;
// Ports par défaut selon protocole
static const _defaultPorts = {
NymeaProtocol.tcpRaw: 2222,
NymeaProtocol.webSocket: 4444,
};
@override
void initState() {
super.initState();
_protocol = widget.service.protocol;
_hostCtrl = TextEditingController(text: widget.service.host);
_portCtrl = TextEditingController(text: widget.service.port.toString());
_userCtrl = TextEditingController(text: widget.service.username);
_passCtrl = TextEditingController(text: widget.service.password);
_verbose = widget.service.verboseLog;
}
@override
void dispose() {
_hostCtrl.dispose();
_portCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
void _onProtocolChanged(NymeaProtocol p) {
setState(() {
_protocol = p;
// Mettre à jour le port par défaut si l'utilisateur n'a pas changé
final currentPort = int.tryParse(_portCtrl.text) ?? 0;
final otherDefault = _defaultPorts[_protocol == NymeaProtocol.tcpRaw
? NymeaProtocol.webSocket
: NymeaProtocol.tcpRaw]!;
if (currentPort == otherDefault || currentPort == 0) {
_portCtrl.text = _defaultPorts[p]!.toString();
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.router_rounded,
color: AppTheme.primaryGreen, size: 20),
),
const SizedBox(width: 10),
const Text('Connexion Nymea'),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélecteur de protocole
const Text('Protocole',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textLight)),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
_ProtoTab(
label: 'TCP brut',
subtitle: 'port 2222',
icon: Icons.cable_rounded,
selected: _protocol == NymeaProtocol.tcpRaw,
onTap: () => _onProtocolChanged(NymeaProtocol.tcpRaw),
),
_ProtoTab(
label: 'WebSocket',
subtitle: 'port 4444',
icon: Icons.wifi_rounded,
selected: _protocol == NymeaProtocol.webSocket,
onTap: () => _onProtocolChanged(NymeaProtocol.webSocket),
),
],
),
),
const SizedBox(height: 6),
// Info protocole
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.07),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_protocol == NymeaProtocol.tcpRaw
? '💡 TCP brut : nymea envoie un message de bienvenue dès la connexion. Utilisé par l\'app nymea officielle.'
: '💡 WebSocket : le client envoie JSONRPC.Hello en premier. Activez-le dans nymea → Paramètres → Serveur WebSocket.',
style: const TextStyle(fontSize: 11, color: AppTheme.textLight),
),
),
const SizedBox(height: 14),
// Hôte
TextField(
controller: _hostCtrl,
decoration: const InputDecoration(
labelText: 'Adresse IP / Hôte',
prefixIcon: Icon(Icons.dns_outlined),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
// Port
TextField(
controller: _portCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Port',
prefixIcon: Icon(Icons.settings_ethernet),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
// Identifiants
TextField(
controller: _userCtrl,
decoration: const InputDecoration(
labelText: 'Utilisateur',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
isDense: true,
),
),
// Debug verbose
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.terminal_rounded, size: 16,
color: AppTheme.textLight),
const SizedBox(width: 8),
const Expanded(
child: Text('Logs JSON verbose',
style: TextStyle(fontSize: 13, color: AppTheme.textDark)),
),
Switch(
value: _verbose,
activeThumbColor: AppTheme.primaryGreen,
onChanged: (v) {
setState(() => _verbose = v);
widget.service.verboseLog = v;
},
),
],
),
// Erreur
if (widget.service.connectionError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.service.connectionError!,
style: const TextStyle(color: Colors.red, fontSize: 11),
),
),
],
),
),
],
],
),
actions: [
// Mode démo
TextButton.icon(
icon: const Icon(Icons.science_outlined, size: 16),
label: const Text('Mode démo'),
onPressed: () {
widget.service.startSimulation();
Navigator.pop(context);
},
),
// Connecter
ElevatedButton.icon(
icon: _connecting
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.link_rounded, size: 16),
label: const Text('Connecter'),
onPressed: _connecting
? null
: () async {
setState(() => _connecting = true);
final ok = await widget.service.connect(
_hostCtrl.text.trim(),
int.tryParse(_portCtrl.text.trim()) ??
_defaultPorts[_protocol]!,
protocol: _protocol,
username: _userCtrl.text.trim(),
password: _passCtrl.text,
);
if (ok && context.mounted) Navigator.pop(context);
if (mounted) setState(() => _connecting = false);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
),
),
],
);
}
}
class _ProtoTab extends StatelessWidget {
final String label;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ProtoTab({
required this.label,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: selected
? [BoxShadow(
color: Colors.black.withValues(alpha:0.08),
blurRadius: 4, offset: const Offset(0, 1))]
: null,
),
child: Column(
children: [
Icon(icon,
size: 20,
color: selected ? AppTheme.primaryGreen : AppTheme.textLight),
const SizedBox(height: 4),
Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: selected ? AppTheme.primaryGreen : AppTheme.textLight)),
Text(subtitle,
style: const TextStyle(
fontSize: 10, color: AppTheme.textLight)),
],
),
),
),
);
}
}

View File

@ -0,0 +1,403 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:provider/provider.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
class EnergyScreen extends StatefulWidget {
const EnergyScreen({super.key});
@override
State<EnergyScreen> createState() => _EnergyScreenState();
}
class _EnergyScreenState extends State<EnergyScreen> {
String _period = 'Jour';
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final points = service.historyPoints;
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: const Text(
'Énergie',
style: TextStyle(
fontWeight: FontWeight.bold, color: AppTheme.textDark),
),
actions: [
IconButton(
icon: const Icon(Icons.file_download_outlined,
color: AppTheme.textDark),
onPressed: () {},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Period selector
Card(
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(
children: _periods.map((p) {
final selected = _period == p;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _period = p),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: selected
? AppTheme.primaryGreen
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Text(
p,
textAlign: TextAlign.center,
style: TextStyle(
color: selected
? Colors.white
: AppTheme.textLight,
fontWeight: selected
? FontWeight.bold
: FontWeight.normal,
fontSize: 13,
),
),
),
),
);
}).toList(),
),
),
),
const SizedBox(height: 12),
// Summary cards
_SummaryRow(service: service),
const SizedBox(height: 12),
// Production chart
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Production & Consommation',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 15)),
const SizedBox(height: 4),
const _Legend(),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: points.isEmpty
? const Center(child: CircularProgressIndicator())
: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: 5000,
barTouchData: BarTouchData(enabled: true),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx % 3 != 0 ||
idx >= points.length) {
return const SizedBox();
}
return Text(
'${points[idx].time.hour}h',
style: const TextStyle(
fontSize: 10,
color: AppTheme.textLight),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) => Text(
'${(value / 1000).toStringAsFixed(1)}k',
style: const TextStyle(
fontSize: 9,
color: AppTheme.textLight),
),
),
),
rightTitles: const AxisTitles(
sideTitles:
SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles:
SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) =>
FlLine(
color: Colors.grey.shade100,
strokeWidth: 1,
),
),
barGroups: points
.asMap()
.entries
.map(
(e) => BarChartGroupData(
x: e.key,
barsSpace: 2,
barRods: [
BarChartRodData(
toY: e.value.pvWh,
color: AppTheme.solarYellow,
width: 5,
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(3)),
),
BarChartRodData(
toY: e.value.homeWh,
color: AppTheme.homeBlue,
width: 5,
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(3)),
),
],
),
)
.toList(),
),
),
),
],
),
),
),
const SizedBox(height: 12),
// Grid line chart
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Import/Export réseau',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 15)),
const SizedBox(height: 16),
SizedBox(
height: 160,
child: points.isEmpty
? const Center(child: CircularProgressIndicator())
: LineChart(
LineChartData(
minY: -2000,
maxY: 3000,
lineTouchData:
LineTouchData(enabled: true),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx % 4 != 0 ||
idx >= points.length) {
return const SizedBox();
}
return Text(
'${points[idx].time.hour}h',
style: const TextStyle(
fontSize: 10,
color: AppTheme.textLight),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
getTitlesWidget: (v, _) => Text(
'${v.toStringAsFixed(0)} W',
style: const TextStyle(
fontSize: 9,
color: AppTheme.textLight),
),
),
),
rightTitles: const AxisTitles(
sideTitles:
SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles:
SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(
drawVerticalLine: false,
getDrawingHorizontalLine: (v) => FlLine(
color: v == 0
? Colors.grey.shade400
: Colors.grey.shade100,
strokeWidth: v == 0 ? 1.5 : 1),
),
lineBarsData: [
LineChartBarData(
spots: points
.asMap()
.entries
.map((e) => FlSpot(
e.key.toDouble(),
e.value.gridWh))
.toList(),
isCurved: true,
color: Colors.orange,
barWidth: 2,
belowBarData: BarAreaData(
show: true,
color: Colors.orange.withValues(alpha:0.1),
),
dotData: const FlDotData(show: false),
),
],
),
),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
},
);
}
}
class _Legend extends StatelessWidget {
const _Legend();
@override
Widget build(BuildContext context) {
return Row(
children: [
_dot(AppTheme.solarYellow),
const SizedBox(width: 4),
const Text('Production', style: TextStyle(fontSize: 12, color: AppTheme.textLight)),
const SizedBox(width: 16),
_dot(AppTheme.homeBlue),
const SizedBox(width: 4),
const Text('Consommation', style: TextStyle(fontSize: 12, color: AppTheme.textLight)),
],
);
}
Widget _dot(Color c) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
);
}
class _SummaryRow extends StatelessWidget {
final NymeaService service;
const _SummaryRow({required this.service});
@override
Widget build(BuildContext context) {
final d = service.energyData;
return Row(
children: [
_SummaryCard(
label: 'Production',
value: '${(d.dayProductionWh / 1000).toStringAsFixed(2)} kWh',
color: AppTheme.solarYellow,
icon: Icons.wb_sunny_rounded),
const SizedBox(width: 8),
_SummaryCard(
label: 'Consommation',
value: '${(d.daySelfConsumptionWh / 1000).toStringAsFixed(2)} kWh',
color: AppTheme.homeBlue,
icon: Icons.home_rounded),
const SizedBox(width: 8),
_SummaryCard(
label: 'Gains',
value: '${d.dayGains.toStringAsFixed(2)}',
color: Colors.amber,
icon: Icons.euro_rounded),
],
);
}
}
class _SummaryCard extends StatelessWidget {
final String label;
final String value;
final Color color;
final IconData icon;
const _SummaryCard({
required this.label,
required this.value,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 6),
Text(value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: 13)),
const SizedBox(height: 2),
Text(label,
style: const TextStyle(
fontSize: 10, color: AppTheme.textLight)),
],
),
),
),
);
}
}

View File

@ -0,0 +1,706 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/energy_data.dart';
import '../models/nymea_models.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final favs = service.favoriteWidgets;
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: const Text('Favoris',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark)),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline,
color: AppTheme.primaryGreen),
onPressed: () => _showAddFavorite(context, service),
),
],
),
body: favs.isEmpty
? _EmptyState(onAdd: () => _showAddFavorite(context, service))
: ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: favs.length,
onReorder: service.reorderFavorites,
proxyDecorator: (child, index, animation) => Material(
elevation: 6,
borderRadius: BorderRadius.circular(16),
child: child,
),
itemBuilder: (context, index) {
final fav = favs[index];
return Padding(
key: ValueKey(fav.id),
padding: const EdgeInsets.only(bottom: 12),
child: _FavoriteCard(
fav: fav,
service: service,
onRemove: () => service.removeFavorite(fav.id),
),
);
},
),
);
},
);
}
void _showAddFavorite(BuildContext context, NymeaService service) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (_) => _AddFavoriteSheet(service: service),
);
}
}
// Favorite Card
class _FavoriteCard extends StatelessWidget {
final FavoriteWidget fav;
final NymeaService service;
final VoidCallback onRemove;
const _FavoriteCard({
required this.fav,
required this.service,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with drag handle + title + remove
Row(
children: [
const Icon(Icons.drag_handle, color: AppTheme.textLight),
const SizedBox(width: 8),
Expanded(
child: Text(fav.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 15)),
),
IconButton(
icon: const Icon(Icons.close, size: 18, color: AppTheme.textLight),
onPressed: onRemove,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 10),
// Content based on type
_buildContent(context),
],
),
),
);
}
Widget _buildContent(BuildContext context) {
final d = service.energyData;
switch (fav.type) {
case FavoriteType.pvPower:
return _BigMetric(
value: _fmt(d.pvPower),
unit: d.pvPower >= 1000 ? 'kW' : 'W',
label: 'Production solaire',
icon: Icons.wb_sunny_rounded,
color: AppTheme.solarYellow,
trend: d.pvPower > 100 ? '↑ En production' : 'Pas de production',
);
case FavoriteType.homePower:
return _BigMetric(
value: _fmt(d.homePower),
unit: d.homePower >= 1000 ? 'kW' : 'W',
label: 'Consommation maison',
icon: Icons.home_rounded,
color: AppTheme.homeBlue,
trend: 'Dont ${d.selfConsumptionRate.toStringAsFixed(0)}% solaire',
);
case FavoriteType.batterySOC:
return _BatteryWidget(data: d);
case FavoriteType.gridPower:
return _BigMetric(
value: _fmt(d.gridPower.abs()),
unit: d.gridPower.abs() >= 1000 ? 'kW' : 'W',
label: d.gridPower >= 0 ? 'Import réseau' : 'Export réseau',
icon: Icons.electrical_services_rounded,
color: d.gridPower >= 0 ? Colors.orange : AppTheme.primaryGreen,
trend: d.gridPower >= 0 ? '↓ Achat réseau' : '↑ Vente réseau',
);
case FavoriteType.rates:
return _RatesWidget(data: d);
case FavoriteType.evCharger:
return _EVWidget(data: d, service: service);
case FavoriteType.thingState:
return _ThingStateWidget(fav: fav, service: service);
case FavoriteType.chart:
return _MiniChart(historyPoints: service.historyPoints);
}
}
String _fmt(double w) {
if (w >= 1000) return (w / 1000).toStringAsFixed(1);
return w.toStringAsFixed(0);
}
}
// Sub-widgets
class _BigMetric extends StatelessWidget {
final String value;
final String unit;
final String label;
final IconData icon;
final Color color;
final String trend;
const _BigMetric({
required this.value,
required this.unit,
required this.label,
required this.icon,
required this.color,
required this.trend,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: color, size: 30),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.bold,
color: color)),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(unit,
style: const TextStyle(
fontSize: 16, color: AppTheme.textLight)),
),
],
),
Text(label,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight)),
const SizedBox(height: 2),
Text(trend,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500)),
],
),
],
);
}
}
class _BatteryWidget extends StatelessWidget {
final EnergyData data;
const _BatteryWidget({required this.data});
@override
Widget build(BuildContext context) {
final soc = data.batterySOC;
final isCharging = data.batteryPower > 0;
final color = soc > 50
? AppTheme.batteryGreen
: soc > 20
? Colors.orange
: Colors.red;
return Row(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: soc / 100,
strokeWidth: 8,
backgroundColor: color.withValues(alpha:0.15),
valueColor: AlwaysStoppedAnimation(color),
),
),
Column(
children: [
Text('${soc.toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color)),
Icon(
isCharging
? Icons.bolt
: Icons.battery_std_rounded,
size: 14,
color: color),
],
),
],
),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('État de charge',
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight)),
const SizedBox(height: 4),
Text(
isCharging
? '⚡ En charge ${_fmt(data.batteryPower)} W'
: data.batteryPower < -10
? '↓ Décharge ${_fmt(data.batteryPower.abs())} W'
: '○ Standby',
style: TextStyle(
fontSize: 13,
color: color,
fontWeight: FontWeight.w600),
),
],
),
],
);
}
String _fmt(double w) => w >= 1000
? '${(w / 1000).toStringAsFixed(1)} kW'
: '${w.toStringAsFixed(0)} W';
}
class _RatesWidget extends StatelessWidget {
final EnergyData data;
const _RatesWidget({required this.data});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _RateItem(
label: 'Autoconsommation',
value: data.selfConsumptionRate,
color: AppTheme.solarYellow)),
const SizedBox(width: 16),
Expanded(
child: _RateItem(
label: 'Autonomie',
value: data.autonomyRate,
color: AppTheme.homeBlue)),
],
);
}
}
class _RateItem extends StatelessWidget {
final String label;
final double value;
final Color color;
const _RateItem(
{required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('${value.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: color)),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (value / 100).clamp(0, 1),
backgroundColor: color.withValues(alpha:0.15),
valueColor: AlwaysStoppedAnimation(color),
minHeight: 7,
),
),
const SizedBox(height: 4),
Text(label,
style: const TextStyle(fontSize: 11, color: AppTheme.textLight),
textAlign: TextAlign.center),
],
);
}
}
class _EVWidget extends StatelessWidget {
final EnergyData data;
final NymeaService service;
const _EVWidget({required this.data, required this.service});
@override
Widget build(BuildContext context) {
final modeColors = {
ChargingMode.pv: AppTheme.pvGreen,
ChargingMode.minPv: AppTheme.minPvBlue,
ChargingMode.boost: AppTheme.boostRed,
};
final modeLabels = {
ChargingMode.pv: 'PV',
ChargingMode.minPv: 'Min+PV',
ChargingMode.boost: 'Boost',
};
final color = modeColors[data.chargingMode]!;
return Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: color.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(14)),
child: Icon(Icons.electric_car_rounded, color: color, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${data.chargingPower.toStringAsFixed(1)} kW',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color)),
Text('Mode : ${modeLabels[data.chargingMode]}',
style: TextStyle(fontSize: 12, color: color)),
Text(
'${data.solarSourcePercent.toStringAsFixed(0)}% solaire · Réseau ${data.gridLimitOk ? "OK" : "!"}',
style: const TextStyle(
fontSize: 11, color: AppTheme.textLight)),
],
),
),
// Quick mode toggle
Column(
children: ChargingMode.values.map((m) {
final c = modeColors[m]!;
return GestureDetector(
onTap: () => service.setChargingMode(m),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: data.chargingMode == m ? c : c.withValues(alpha:0.3),
shape: BoxShape.circle,
),
),
);
}).toList(),
),
],
);
}
}
class _ThingStateWidget extends StatelessWidget {
final FavoriteWidget fav;
final NymeaService service;
const _ThingStateWidget(
{required this.fav, required this.service});
@override
Widget build(BuildContext context) {
final thing = service.things.firstWhere(
(t) => t.id == fav.thingId,
orElse: () => NymeaThing(
id: '', name: 'Introuvable', thingClassId: '',
setupStatus: '', paramValues: []));
final value = fav.stateTypeId != null
? thing.stateValue(fav.stateTypeId!)
: null;
return Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(12)),
child: const Icon(Icons.device_hub_rounded,
color: AppTheme.primaryGreen, size: 26),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(thing.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 14)),
const SizedBox(height: 4),
Text(value?.toString() ?? '',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.primaryGreen)),
],
),
],
);
}
}
class _MiniChart extends StatelessWidget {
final List<HistoryPoint> historyPoints;
const _MiniChart({required this.historyPoints});
@override
Widget build(BuildContext context) {
if (historyPoints.isEmpty) {
return const SizedBox(
height: 60,
child: Center(child: Text('Pas de données', style: TextStyle(color: AppTheme.textLight))));
}
final max = historyPoints.map((p) => p.pvWh).reduce((a, b) => a > b ? a : b);
return SizedBox(
height: 60,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: historyPoints.map((p) {
final h = max > 0 ? (p.pvWh / max) * 50 : 0.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Container(
height: h.clamp(2, 50),
decoration: BoxDecoration(
color: AppTheme.solarYellow.withValues(alpha:0.7),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(3)),
),
),
),
);
}).toList(),
),
);
}
}
// Empty state
class _EmptyState extends StatelessWidget {
final VoidCallback onAdd;
const _EmptyState({required this.onAdd});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.star_outline_rounded,
color: AppTheme.primaryGreen, size: 48),
),
const SizedBox(height: 16),
const Text('Aucun favori',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: AppTheme.textDark)),
const SizedBox(height: 8),
const Text('Ajoutez des widgets pour un accès rapide',
style: TextStyle(color: AppTheme.textLight)),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter un widget'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
}
// Add favorite sheet
class _AddFavoriteSheet extends StatelessWidget {
final NymeaService service;
const _AddFavoriteSheet({required this.service});
@override
Widget build(BuildContext context) {
final options = [
_FavOption(FavoriteType.pvPower, '☀️ Production PV',
'Puissance solaire instantanée', Icons.wb_sunny_rounded,
AppTheme.solarYellow),
_FavOption(FavoriteType.homePower, '🏠 Consommation',
'Puissance consommée', Icons.home_rounded, AppTheme.homeBlue),
_FavOption(FavoriteType.batterySOC, '🔋 Batterie',
'État de charge', Icons.battery_charging_full_rounded,
AppTheme.batteryGreen),
_FavOption(FavoriteType.gridPower, '⚡ Réseau',
'Import / Export réseau', Icons.electrical_services_rounded,
AppTheme.gridGray),
_FavOption(FavoriteType.rates, '📊 Taux',
'Autoconsommation & autonomie', Icons.pie_chart_rounded,
Colors.deepPurple),
_FavOption(FavoriteType.evCharger, '🚗 Borne EV',
'Recharge véhicule', Icons.electric_car_rounded,
AppTheme.minPvBlue),
_FavOption(FavoriteType.chart, '📈 Graphique',
'Production journalière', Icons.bar_chart_rounded,
Colors.teal),
];
return DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
expand: false,
builder: (_, controller) => Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2)),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text('Ajouter un widget',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 12),
Expanded(
child: ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: options.map((opt) {
final alreadyAdded = service.favoriteWidgets
.any((f) => f.type == opt.type);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: opt.color.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(12)),
child: Icon(opt.icon, color: opt.color),
),
title: Text(opt.title,
style: const TextStyle(
fontWeight: FontWeight.bold)),
subtitle: Text(opt.subtitle,
style: const TextStyle(fontSize: 12)),
trailing: alreadyAdded
? const Icon(Icons.check_circle,
color: AppTheme.primaryGreen)
: const Icon(Icons.add_circle_outline,
color: AppTheme.primaryGreen),
onTap: alreadyAdded
? null
: () {
service.addFavorite(FavoriteWidget(
id: 'fw_${opt.type.name}_${DateTime.now().millisecondsSinceEpoch}',
type: opt.type,
title: opt.title.split(' ').skip(1).join(' '),
));
Navigator.pop(context);
},
),
);
}).toList(),
),
),
],
),
);
}
}
class _FavOption {
final FavoriteType type;
final String title;
final String subtitle;
final IconData icon;
final Color color;
const _FavOption(
this.type, this.title, this.subtitle, this.icon, this.color);
}

View File

@ -0,0 +1,655 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
//
// ThingDetailScreen fiche complète d'un appareil
//
class ThingDetailScreen extends StatelessWidget {
final NymeaThing thing;
const ThingDetailScreen({super.key, required this.thing});
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
// Récupérer le thing à jour (les états sont mis à jour en temps réel)
final liveThing = service.things.firstWhere(
(t) => t.id == thing.id,
orElse: () => thing,
);
NymeaThingClass? cls;
try {
cls = service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
} catch (_) {}
final catInfo = cls != null
? categoryInfoMap[cls.category] ?? categoryInfoMap[ThingCategory.other]!
: categoryInfoMap[ThingCategory.other]!;
// Grouper les états par thème
final stateGroups = _groupStates(cls?.stateTypes ?? [], liveThing);
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
body: CustomScrollView(
slivers: [
// SliverAppBar avec header coloré
SliverAppBar(
expandedHeight: 160,
pinned: true,
backgroundColor: catInfo.color,
foregroundColor: Colors.white,
actions: [
if (!service.isSimulation)
IconButton(
icon: const Icon(Icons.edit_outlined, color: Colors.white),
onPressed: () =>
_showRenameDialog(context, service, liveThing),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
catInfo.color,
catInfo.color.withValues(alpha: 0.7),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 56, 20, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: Icon(catInfo.icon,
color: Colors.white, size: 30),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(liveThing.name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(
cls?.displayName ?? catInfo.label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 13),
),
],
),
),
_OnlineBadge(thing: liveThing, thingClass: cls),
],
),
),
),
),
),
),
// Contenu
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Actions rapides (si la classe en a)
if (cls != null && cls.actionTypes.isNotEmpty) ...[
_ActionsSection(thing: liveThing, thingClass: cls),
const SizedBox(height: 16),
],
// États groupés
...stateGroups.entries.map((entry) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(title: entry.key),
const SizedBox(height: 8),
Card(
child: Column(
children: entry.value.asMap().entries.map((e) {
final isLast = e.key == entry.value.length - 1;
return _StateRow(
stateType: e.value,
value: liveThing.stateValue(e.value.id),
isLast: isLast,
color: catInfo.color,
);
}).toList(),
),
),
const SizedBox(height: 16),
],
)),
// Interfaces nymea
if (cls != null && cls.interfaces.isNotEmpty) ...[
_SectionHeader(title: 'Interfaces'),
const SizedBox(height: 8),
Wrap(
spacing: 8, runSpacing: 6,
children: cls.interfaces.map((iface) => Chip(
label: Text(iface,
style: const TextStyle(fontSize: 12)),
backgroundColor: catInfo.color.withValues(alpha: 0.1),
side: BorderSide(color: catInfo.color.withValues(alpha: 0.3)),
padding: const EdgeInsets.symmetric(horizontal: 4),
)).toList(),
),
const SizedBox(height: 16),
],
// Section Réglages
if (cls != null && cls.settingsTypes.isNotEmpty) ...[
const _SectionHeader(title: 'Réglages'),
const SizedBox(height: 8),
Card(
child: Column(
children: cls.settingsTypes.asMap().entries.map((e) {
final isLast = e.key == cls!.settingsTypes.length - 1;
final current = liveThing.settingValue(e.value.id);
return _SettingRow(
settingType: e.value,
value: current,
isLast: isLast,
onEdit: (newVal) => service.setThingSettings(
liveThing.id, e.value.id, newVal),
);
}).toList(),
),
),
const SizedBox(height: 16),
],
// Info technique
_SectionHeader(title: 'Informations'),
const SizedBox(height: 8),
Card(
child: Column(children: [
_InfoRow('ID', liveThing.id, isLast: false),
_InfoRow('Classe', liveThing.thingClassId, isLast: false),
_InfoRow('Statut setup', liveThing.setupStatus
.replaceAll('ThingSetupStatus', ''), isLast: true),
]),
),
const SizedBox(height: 16),
// Section Gestion
if (!service.isSimulation) ...[
const _SectionHeader(title: 'Gestion'),
const SizedBox(height: 8),
Card(
child: Column(children: [
ListTile(
leading: const Icon(Icons.edit_rounded,
color: AppTheme.primaryGreen),
title: const Text('Renommer'),
onTap: () =>
_showRenameDialog(context, service, liveThing),
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.delete_outline_rounded,
color: Colors.red),
title: const Text('Supprimer',
style: TextStyle(color: Colors.red)),
onTap: () =>
_showRemoveDialog(context, service, liveThing),
),
]),
),
const SizedBox(height: 16),
],
const SizedBox(height: 60),
]),
),
),
],
),
);
}
// Groupement des états par thème
Map<String, List<NymeaStateType>> _groupStates(
List<NymeaStateType> stateTypes,
NymeaThing thing,
) {
final Map<String, List<NymeaStateType>> groups = {};
for (final st in stateTypes) {
final group = _groupName(st.name);
groups.putIfAbsent(group, () => []).add(st);
}
// Trier : Puissance en premier, puis alphabétique
final ordered = <String, List<NymeaStateType>>{};
const firstGroups = ['Puissance', 'Énergie', 'Mesures électriques', 'État'];
for (final g in firstGroups) {
if (groups.containsKey(g)) ordered[g] = groups[g]!;
}
for (final entry in groups.entries) {
if (!ordered.containsKey(entry.key)) ordered[entry.key] = entry.value;
}
return ordered;
}
String _groupName(String stateName) {
final n = stateName.toLowerCase();
if (n.contains('power') || n.contains('currentpower')) { return 'Puissance'; }
if (n.contains('energy') || n.contains('total')) { return 'Énergie'; }
if (n.contains('voltage') || n.contains('current') || n.contains('frequency')
|| n.contains('ampere') || n.contains('volt') || n.contains('hertz')
|| n.contains('powerfactor')) { return 'Mesures électriques'; }
if (n.contains('temperature') || n.contains('humidity') || n.contains('pressure')
|| n.contains('co2') || n.contains('lux')) { return 'Environnement'; }
if (n.contains('connected') || n.contains('reachable') || n.contains('status')
|| n.contains('mode') || n.contains('on')) { return 'État'; }
if (n.contains('soc') || n.contains('battery') || n.contains('charging')) { return 'Batterie'; }
return 'Divers';
}
void _showRenameDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
final ctrl = TextEditingController(text: t.name);
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
onPressed: () async {
final name = ctrl.text.trim();
if (name.isNotEmpty) await svc.renameThing(t.id, name);
if (c.mounted) Navigator.pop(c);
},
child: const Text('Renommer'),
),
],
),
);
}
void _showRemoveDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Supprimer "${t.name}" définitivement ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white),
onPressed: () async {
await svc.removeThing(t.id);
if (c.mounted) {
Navigator.pop(c);
Navigator.pop(ctx);
}
},
child: const Text('Supprimer'),
),
],
),
);
}
}
// Section actions
class _ActionsSection extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass thingClass;
const _ActionsSection({required this.thing, required this.thingClass});
@override
Widget build(BuildContext context) {
final service = context.read<NymeaService>();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const _SectionHeader(title: 'Actions'),
const SizedBox(height: 8),
Wrap(
spacing: 10, runSpacing: 8,
children: thingClass.actionTypes.map((action) => ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(action.displayName,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
onPressed: () => _executeAction(context, service, action),
)).toList(),
),
]);
}
void _executeAction(BuildContext context, NymeaService service,
NymeaActionType action) async {
if (action.paramTypes.isEmpty) {
// Action sans paramètre exécuter directement
await service.executeAction(
thingId: thing.id,
actionTypeId: action.id,
params: {},
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Action "${action.displayName}" exécutée'),
backgroundColor: AppTheme.primaryGreen,
duration: const Duration(seconds: 2),
));
}
} else {
// Action avec paramètres dialog
_showActionParamDialog(context, service, action);
}
}
void _showActionParamDialog(BuildContext context, NymeaService service,
NymeaActionType action) {
// Simple pour l'instant : juste afficher les paramètres requis
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(action.displayName),
content: Column(
mainAxisSize: MainAxisSize.min,
children: action.paramTypes.map((p) => ListTile(
dense: true,
title: Text(p['displayName']?.toString() ?? p['name']?.toString() ?? ''),
subtitle: Text('Type: ${p['type']}'),
)).toList(),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: const Text('Fermer')),
],
),
);
}
}
// Widgets utilitaires
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(title,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w700,
color: AppTheme.textLight, letterSpacing: 0.5)),
);
}
class _StateRow extends StatelessWidget {
final NymeaStateType stateType;
final dynamic value;
final bool isLast;
final Color color;
const _StateRow({
required this.stateType, required this.value,
required this.isLast, required this.color,
});
@override
Widget build(BuildContext context) {
final formatted = stateType.formatValue(value);
final isBool = stateType.type == 'Bool';
return Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(stateType.displayName,
style: const TextStyle(
fontSize: 14, color: AppTheme.textDark,
fontWeight: FontWeight.w500)),
if (stateType.name != stateType.displayName)
Text(stateType.name,
style: const TextStyle(
fontSize: 10, color: AppTheme.textLight)),
]),
),
if (isBool)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: (value == true || value == 'true')
? AppTheme.primaryGreen.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(formatted,
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.bold,
color: (value == true || value == 'true')
? AppTheme.primaryGreen : AppTheme.textLight)),
)
else
Text(formatted,
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold,
color: color)),
]),
),
if (!isLast)
const Divider(height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final bool isLast;
const _InfoRow(this.label, this.value, {required this.isLast});
@override
Widget build(BuildContext context) => Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(children: [
Text(label,
style: const TextStyle(fontSize: 13, color: AppTheme.textLight)),
const SizedBox(width: 12),
Expanded(
child: SelectableText(value,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500,
color: AppTheme.textDark)),
),
]),
),
if (!isLast) const Divider(height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}
class _SettingRow extends StatelessWidget {
final NymeaStateType settingType;
final dynamic value;
final bool isLast;
final Future<bool> Function(dynamic) onEdit;
const _SettingRow({
required this.settingType,
required this.value,
required this.isLast,
required this.onEdit,
});
@override
Widget build(BuildContext context) {
final formatted = settingType.formatValue(value);
return Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(settingType.displayName,
style: const TextStyle(
fontSize: 14, color: AppTheme.textDark,
fontWeight: FontWeight.w500)),
if (settingType.name != settingType.displayName)
Text(settingType.name,
style: const TextStyle(
fontSize: 10, color: AppTheme.textLight)),
]),
),
Text(formatted,
style: const TextStyle(
fontSize: 14, color: AppTheme.textLight)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18,
color: AppTheme.textLight),
onPressed: () => _showEditDialog(context),
),
]),
),
if (!isLast)
const Divider(height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}
void _showEditDialog(BuildContext context) {
if (settingType.type == 'Bool') {
final current = value == true || value == 'true';
onEdit(!current);
return;
}
final ctrl = TextEditingController(text: value?.toString() ?? '');
showDialog(
context: context,
builder: (c) => AlertDialog(
title: Text(settingType.displayName),
content: TextField(
controller: ctrl,
autofocus: true,
keyboardType: (settingType.type == 'Int' || settingType.type == 'Double')
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.text,
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixText: settingType.unit,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
onPressed: () {
dynamic newVal = ctrl.text.trim();
if (settingType.type == 'Int') {
newVal = int.tryParse(ctrl.text.trim()) ?? value;
} else if (settingType.type == 'Double') {
newVal = double.tryParse(ctrl.text.trim()) ?? value;
}
onEdit(newVal);
Navigator.pop(c);
},
child: const Text('Enregistrer'),
),
],
),
);
}
}
class _OnlineBadge extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
const _OnlineBadge({required this.thing, required this.thingClass});
bool _isOnline() {
final connType = thingClass?.stateTypes
.where((s) => s.name.toLowerCase() == 'connected');
if (connType != null && connType.isNotEmpty) {
final val = thing.stateValue(connType.first.id);
return val == true || val == 'true';
}
return thing.isSetupComplete;
}
@override
Widget build(BuildContext context) {
final online = _isOnline();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: online ? Colors.greenAccent : Colors.grey[300],
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(online ? 'En ligne' : 'Hors ligne',
style: const TextStyle(
color: Colors.white, fontSize: 12,
fontWeight: FontWeight.w600)),
]),
);
}
}

View File

@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
import 'thing_detail_screen.dart';
class ThingsScreen extends StatefulWidget {
const ThingsScreen({super.key});
@override
State<ThingsScreen> createState() => _ThingsScreenState();
}
class _ThingsScreenState extends State<ThingsScreen> {
final Set<ThingCategory> _collapsed = {};
String _searchQuery = '';
bool _showSearch = false;
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
// Garder tous les things avec un id valide (le filtre setupStatus était trop strict)
final things = service.things.where((t) => t.id.isNotEmpty).toList();
final thingClasses = service.thingClasses;
final filtered = _searchQuery.isEmpty
? things
: things.where((t) =>
t.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
final Map<ThingCategory, List<NymeaThing>> grouped = {};
for (final thing in filtered) {
final cls = _classFor(thing, thingClasses);
final cat = cls?.category ?? ThingCategory.other;
grouped.putIfAbsent(cat, () => []).add(thing);
}
const orderedCats = [
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.hvac, ThingCategory.lighting,
ThingCategory.sensors, ThingCategory.network, ThingCategory.notifications,
ThingCategory.weather, ThingCategory.media, ThingCategory.other,
];
final cats = orderedCats.where((c) => grouped.containsKey(c)).toList();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: _buildAppBar(service, things.length),
body: !service.isConnected
? _buildDisconnected()
: (things.isEmpty && !service.thingsLoaded)
? _buildLoading()
: things.isEmpty
? _buildEmpty()
: cats.isEmpty
// Classes pas encore chargées afficher things en mode simple
? _buildThingsSimple(things, service)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: cats.length,
itemBuilder: (context, i) {
final cat = cats[i];
return _CategorySection(
info: categoryInfoMap[cat]!,
things: grouped[cat]!,
thingClasses: thingClasses,
collapsed: _collapsed.contains(cat),
onToggle: () => setState(() {
if (_collapsed.contains(cat)) {
_collapsed.remove(cat);
} else {
_collapsed.add(cat);
}
}),
onTap: (t) => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ThingDetailScreen(thing: t)),
),
);
},
),
);
}
AppBar _buildAppBar(NymeaService service, int count) {
return AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: _showSearch
? TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: 'Rechercher un appareil...',
border: InputBorder.none,
hintStyle: TextStyle(color: AppTheme.textLight),
),
style: const TextStyle(color: AppTheme.textDark, fontSize: 18),
onChanged: (v) => setState(() => _searchQuery = v),
)
: Row(children: [
const Text('Things',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 20)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.primaryGreen)),
),
]),
actions: [
IconButton(
icon: Icon(_showSearch ? Icons.close : Icons.search,
color: AppTheme.textDark),
onPressed: () => setState(() {
_showSearch = !_showSearch;
_searchQuery = '';
}),
),
IconButton(
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
onPressed: () => service.refresh(),
),
],
);
}
Widget _buildLoading() => const Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
CircularProgressIndicator(color: AppTheme.primaryGreen),
SizedBox(height: 16),
Text('Chargement des appareils...',
style: TextStyle(color: AppTheme.textLight)),
]),
);
Widget _buildDisconnected() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.cloud_off_rounded, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
const Text('Non connecté à nymea',
style: TextStyle(color: AppTheme.textLight, fontSize: 16)),
]),
);
/// Affichage simple quand les ThingClasses ne sont pas encore chargées
Widget _buildThingsSimple(List<NymeaThing> things, NymeaService service) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: things.length,
itemBuilder: (context, i) {
final t = things[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
leading: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.device_hub_rounded,
color: AppTheme.primaryGreen, size: 22),
),
title: Text(t.name,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text(t.setupStatus.replaceAll('ThingSetupStatus', ''),
style: const TextStyle(fontSize: 12, color: AppTheme.textLight)),
trailing: t.setupStatus == 'ThingSetupStatusComplete'
? Container(
width: 8, height: 8,
decoration: const BoxDecoration(
color: AppTheme.primaryGreen,
shape: BoxShape.circle,
),
)
: null,
),
);
},
);
}
Widget _buildEmpty() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.search_off_rounded, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
_searchQuery.isEmpty ? 'Aucun appareil' : 'Aucun résultat pour "$_searchQuery"',
style: const TextStyle(color: AppTheme.textLight, fontSize: 16),
),
]),
);
NymeaThingClass? _classFor(NymeaThing thing, List<NymeaThingClass> classes) {
try { return classes.firstWhere((c) => c.id == thing.thingClassId); }
catch (_) { return null; }
}
}
// Section catégorie
class _CategorySection extends StatelessWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
final bool collapsed;
final VoidCallback onToggle;
final void Function(NymeaThing) onTap;
const _CategorySection({
required this.info, required this.things, required this.thingClasses,
required this.collapsed, required this.onToggle, required this.onTap,
});
NymeaThingClass? _cls(NymeaThing t) {
try { return thingClasses.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Header collapsable
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
),
child: Icon(info.icon, color: info.color, size: 19),
),
const SizedBox(width: 10),
Expanded(
child: Text(info.label,
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 13,
color: AppTheme.textDark, letterSpacing: 0.2)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text('${things.length}',
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.bold,
color: info.color)),
),
const SizedBox(width: 4),
AnimatedRotation(
turns: collapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more,
color: AppTheme.textLight, size: 20),
),
]),
),
),
// Things
AnimatedCrossFade(
duration: const Duration(milliseconds: 220),
crossFadeState: collapsed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Column(
children: [
...things.map((t) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ThingCard(
thing: t,
thingClass: _cls(t),
categoryInfo: info,
onTap: () => onTap(t),
),
)),
const SizedBox(height: 4),
],
),
secondChild: const SizedBox.shrink(),
),
]);
}
}
// ThingCard
class ThingCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo categoryInfo;
final VoidCallback onTap;
const ThingCard({
super.key, required this.thing, required this.thingClass,
required this.categoryInfo, required this.onTap,
});
@override
Widget build(BuildContext context) {
final primaryType = thingClass?.primaryStateType;
final primaryValue = primaryType?.formatValue(thing.stateValue(primaryType.id));
final secondaryStates = _secondaryStates();
final isOnline = _isOnline();
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Ligne principale
Row(children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: categoryInfo.color.withValues(alpha: isOnline ? 0.15 : 0.07),
borderRadius: BorderRadius.circular(13),
),
child: Icon(categoryInfo.icon,
color: isOnline
? categoryInfo.color
: categoryInfo.color.withValues(alpha: 0.4),
size: 26),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(thing.name,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 15,
color: AppTheme.textDark),
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(thingClass?.displayName ?? 'Appareil',
style: const TextStyle(fontSize: 12, color: AppTheme.textLight),
maxLines: 1, overflow: TextOverflow.ellipsis),
]),
),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
if (primaryValue != null)
Text(primaryValue,
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 16,
color: isOnline
? categoryInfo.color
: AppTheme.textLight)),
const SizedBox(height: 4),
_StatusDot(isOnline: isOnline),
]),
const SizedBox(width: 2),
_ThingMenu(thing: thing, onDetail: onTap),
]),
// Chips états secondaires
if (secondaryStates.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(height: 1, color: Color(0xFFEEEEEE)),
const SizedBox(height: 10),
Wrap(
spacing: 7, runSpacing: 6,
children: secondaryStates.map((s) => _StateChip(
label: s.displayName,
value: s.formatValue(thing.stateValue(s.id)),
color: categoryInfo.color,
)).toList(),
),
],
]),
),
),
);
}
bool _isOnline() {
final connType = thingClass?.stateTypes
.where((s) => s.name.toLowerCase() == 'connected');
if (connType != null && connType.isNotEmpty) {
final val = thing.stateValue(connType.first.id);
return val == true || val == 'true';
}
return thing.isSetupComplete;
}
List<NymeaStateType> _secondaryStates() {
if (thingClass == null) return [];
final primary = thingClass!.primaryStateType;
const interesting = [
'voltage', 'current', 'frequency', 'power',
'totalenergyconsumed', 'totalenergyproduced',
'temperature', 'humidity', 'soc',
'chargingmode', 'mode', 'maxchargingcurrent', 'status',
];
return thingClass!.stateTypes
.where((s) =>
s != primary &&
s.name.toLowerCase() != 'connected' &&
interesting.any((i) => s.name.toLowerCase().contains(i)))
.take(3)
.toList();
}
}
// Widgets utilitaires
class _StatusDot extends StatelessWidget {
final bool isOnline;
const _StatusDot({required this.isOnline});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 7, height: 7,
decoration: BoxDecoration(
color: isOnline ? AppTheme.primaryGreen : Colors.grey[400],
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
isOnline ? 'En ligne' : 'Hors ligne',
style: TextStyle(
fontSize: 10,
color: isOnline ? AppTheme.primaryGreen : AppTheme.textLight),
),
]);
}
class _StateChip extends StatelessWidget {
final String label;
final String value;
final Color color;
const _StateChip({required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11, color: color.withValues(alpha: 0.8),
fontWeight: FontWeight.w500)),
),
const SizedBox(width: 5),
Flexible(
child: Text(value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.bold)),
),
]),
);
}
// Menu contextuel
class _ThingMenu extends StatelessWidget {
final NymeaThing thing;
final VoidCallback onDetail;
const _ThingMenu({required this.thing, required this.onDetail});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: AppTheme.textLight, size: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => [
const PopupMenuItem(value: 'detail',
child: Row(children: [
Icon(Icons.info_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Détails & États'),
])),
const PopupMenuItem(value: 'params',
child: Row(children: [
Icon(Icons.tune, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Paramètres'),
])),
const PopupMenuItem(value: 'rename',
child: Row(children: [
Icon(Icons.edit_outlined, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Renommer'),
])),
const PopupMenuItem(value: 'favorite',
child: Row(children: [
Icon(Icons.star_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Ajouter aux favoris'),
])),
],
onSelected: (value) {
switch (value) {
case 'detail': onDetail(); break;
case 'params': _showParamsSheet(context); break;
case 'rename': _showRenameDialog(context); break;
case 'favorite':
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${thing.name} ajouté aux favoris'),
backgroundColor: AppTheme.primaryGreen,
duration: const Duration(seconds: 2),
));
}
},
);
}
void _showParamsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.tune, color: AppTheme.primaryGreen),
const SizedBox(width: 8),
Expanded(child: Text('Paramètres — ${thing.name}',
style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 16, color: AppTheme.textDark))),
]),
const SizedBox(height: 4),
SelectableText('ID: ${thing.id}',
style: const TextStyle(fontSize: 10, color: AppTheme.textLight)),
const Divider(height: 24),
if (thing.paramValues.isEmpty)
const Text('Aucun paramètre configuré.',
style: TextStyle(color: AppTheme.textLight))
else
...thing.paramValues.map((p) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Expanded(child: Text(p['paramTypeId']?.toString() ?? '?',
style: const TextStyle(color: AppTheme.textLight))),
Text(p['value']?.toString() ?? '',
style: const TextStyle(
fontWeight: FontWeight.w600, color: AppTheme.textDark)),
]),
)),
const SizedBox(height: 20),
]),
),
);
}
void _showRenameDialog(BuildContext context) {
final ctrl = TextEditingController(text: thing.name);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl, autofocus: true,
decoration: const InputDecoration(labelText: 'Nouveau nom'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
FilledButton(
onPressed: () {
context.read<NymeaService>().renameThing(thing.id, ctrl.text.trim());
Navigator.pop(ctx);
},
child: const Text('Renommer'),
),
],
),
);
}
}

View File

@ -0,0 +1,946 @@
import 'dart:async';
import 'dart:developer' as dev;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/energy_data.dart';
import '../models/nymea_models.dart';
// Protocole de connexion
enum NymeaProtocol {
/// TCP brut port 2222 nymea parle en premier (\n-delimited JSON)
tcpRaw,
/// WebSocket port 4444 client envoie JSONRPC.Hello en premier
webSocket,
}
/// Service de communication nymea JSON-RPC 2.0
/// Supporte TCP brut (port 2222) et WebSocket (port 4444)
class NymeaService extends ChangeNotifier {
// Transport
Socket? _socket; // TCP raw
WebSocketChannel? _wsChannel; // WebSocket
StreamSubscription? _wsSub;
NymeaProtocol _protocol = NymeaProtocol.tcpRaw;
int _requestId = 1;
final Map<int, Completer<Map<String, dynamic>>> _pendingRequests = {};
final StringBuffer _buffer = StringBuffer(); // TCP fragment buffer
// State
bool _connected = false;
bool _connecting = false;
String _host = '192.168.1.106';
int _port = 2222;
String? _connectionError;
String? _nymeaVersion;
Timer? _heartbeatTimer;
Timer? _simulationTimer;
bool _isSimulation = false;
// Auth
String _token = '';
String _username = '';
String _password = '';
// Debug verbeux
bool _verboseLog = false; // activé depuis l'UI ou à la connexion
bool get verboseLog => _verboseLog;
set verboseLog(bool v) {
_verboseLog = v;
_log('🔧 Verbose logging ${v ? "ON" : "OFF"}', force: true);
}
void _log(String msg, {bool force = false, String? json}) {
if (!force && !_verboseLog) return;
dev.log(msg, name: 'nymea');
if (json != null && _verboseLog) {
// Formatter le JSON pour lisibilité
try {
final parsed = jsonDecode(json);
final pretty = const JsonEncoder.withIndent(' ').convert(parsed);
dev.log(pretty, name: 'nymea.json');
} catch (_) {
dev.log(json, name: 'nymea.json');
}
}
}
EnergyData _energyData = const EnergyData();
List<HistoryPoint> _historyPoints = [];
List<NymeaThing> _things = [];
List<NymeaThingClass> _thingClasses = [];
List<FavoriteWidget> _favoriteWidgets = [];
// Getters
bool get connected => _connected;
bool get isConnected => _connected; // alias pour les screens
bool get connecting => _connecting;
bool get isSimulation => _isSimulation;
NymeaProtocol get protocol => _protocol;
String get host => _host;
int get port => _port;
String? get connectionError => _connectionError;
String? get nymeaVersion => _nymeaVersion;
String get username => _username;
String get password => _password;
EnergyData get energyData => _energyData;
List<HistoryPoint> get historyPoints => _historyPoints;
List<NymeaThing> get things => _things;
List<NymeaThingClass> get thingClasses => _thingClasses;
List<FavoriteWidget> get favoriteWidgets => _favoriteWidgets;
//
// CONNEXION entrée publique
//
Future<bool> connect(
String host,
int port, {
NymeaProtocol protocol = NymeaProtocol.tcpRaw,
String username = '',
String password = '',
}) async {
if (_connecting) return false;
_host = host;
_port = port;
_protocol = protocol;
_username = username;
_password = password;
_connectionError = null;
_connecting = true;
_isSimulation = false;
_simulationTimer?.cancel();
_buffer.clear();
// Charger le token sauvegardé pour cet hôte
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString('nymea_token_$host') ?? '';
if (_token.isNotEmpty) {
_log('🔑 Token restauré depuis SharedPreferences', force: true);
}
notifyListeners();
try {
if (protocol == NymeaProtocol.tcpRaw) {
return await _connectTcp(host, port);
} else {
return await _connectWs(host, port);
}
} catch (e) {
_connectionError = e.toString();
_connected = false;
_connecting = false;
notifyListeners();
return false;
}
}
// TCP brut : nymea parle en PREMIER
Future<bool> _connectTcp(String host, int port) async {
_log('TCP → $host:$port');
_socket = await Socket.connect(host, port,
timeout: const Duration(seconds: 8));
final welcomeCompleter = Completer<Map<String, dynamic>>();
bool welcomeReceived = false;
_socket!.cast<List<int>>().transform(utf8.decoder).listen(
(chunk) {
_buffer.write(chunk);
final raw = _buffer.toString();
int start = 0;
for (int i = 0; i < raw.length; i++) {
if (raw[i] == '\n') {
final line = raw.substring(start, i).trim();
if (line.isNotEmpty) {
try {
final data = jsonDecode(line) as Map<String, dynamic>;
if (!welcomeReceived) {
welcomeReceived = true;
_log('← raw TCP line (${line.length}): ${line.substring(0, line.length.clamp(0, 120))}', force: _verboseLog);
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.complete(data);
}
} else {
_processMessage(data);
}
} catch (e) {
_log('TCP parse error: $e', force: true);
}
}
start = i + 1;
}
}
_buffer.clear();
if (start < raw.length) _buffer.write(raw.substring(start));
},
onError: (e) {
if (!welcomeCompleter.isCompleted) welcomeCompleter.completeError(e);
_onError(e);
},
onDone: () {
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(
Exception('Socket closed before welcome'));
}
_onDone();
},
cancelOnError: false,
);
// Attendre le message de bienvenue de nymea
final welcome = await welcomeCompleter.future
.timeout(const Duration(seconds: 8));
return _finishConnect(welcome);
}
// WebSocket : client parle en PREMIER (JSONRPC.Hello)
Future<bool> _connectWs(String host, int port) async {
_log('WebSocket → ws://$host:$port');
final uri = Uri.parse('ws://$host:$port');
_wsChannel = WebSocketChannel.connect(uri);
// Attendre que la connexion soit établie
try {
await _wsChannel!.ready.timeout(const Duration(seconds: 8));
} catch (_) {
// some versions don't have .ready — ignore
}
_wsSub = _wsChannel!.stream.listen(
(raw) {
try {
// nymea peut envoyer String ou binary (Uint8List)
String str;
if (raw is String) {
str = raw;
} else if (raw is List<int>) {
str = utf8.decode(raw);
} else {
_log('⚠️ WS frame type inattendu: ${raw.runtimeType}', force: true);
return;
}
_log('← WS raw (${str.length} chars): ${str.substring(0, str.length.clamp(0, 80))}');
final data = jsonDecode(str) as Map<String, dynamic>;
_processMessage(data);
} catch (e) {
_log('❌ WS parse error: $e raw type: ${raw.runtimeType}', force: true);
}
},
onError: (e) {
_log('❌ WS stream error: $e', force: true);
_onError(e);
},
onDone: _onDone,
cancelOnError: false,
);
_log('📡 WS listener actif, envoi Hello...', force: true);
// Sur WebSocket, c'est le CLIENT qui envoie Hello en premier
final hello = await _sendRequest('JSONRPC.Hello', {})
.timeout(const Duration(seconds: 8));
return _finishConnect(hello);
}
// Finalisation commune
Future<bool> _finishConnect(Map<String, dynamic> welcome) async {
final p = welcome['params'] as Map<String, dynamic>? ?? welcome;
_nymeaVersion = p['version'] as String?
?? p['serverVersion'] as String?
?? 'nymea';
_log('✅ Connected to nymea $_nymeaVersion'
' via ${_protocol == NymeaProtocol.tcpRaw ? "TCP" : "WebSocket"}');
// Flux auth : si requis ET pas encore authentifié avec token valide
if (p['authenticationRequired'] == true && p['authenticated'] != true) {
if (_token.isNotEmpty) {
_log('🔑 Token existant présent, tentative d\'utilisation...', force: true);
// Le token sera inclus dans les requêtes on verra si ça marche
} else if (_username.isNotEmpty) {
try {
await _doAuthenticate();
} catch (e) {
_connectionError = e.toString();
_connected = false;
_connecting = false;
notifyListeners();
return false;
}
}
}
await _setNotificationsEnabled();
_connected = true;
_connecting = false;
notifyListeners();
await _initialLoad();
_startHeartbeat();
return true;
}
Future<void> _doAuthenticate() async {
_log('🔐 JSONRPC.Authenticate → user=$_username', force: true);
final r = await _sendRequest('JSONRPC.Authenticate', {
'username': _username,
'password': _password,
'deviceName': 'etm-powersync-app',
});
_token = r['params']?['token'] as String? ?? '';
if (_token.isNotEmpty) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('nymea_token_$_host', _token);
_log('✅ Token obtenu et sauvegardé', force: true);
} else {
_log('❌ Authenticate échoué: ${r["params"]}', force: true);
throw Exception('Authentication failed: ${r["params"]}');
}
}
Future<void> _setNotificationsEnabled() async {
try {
await _sendRequest('JSONRPC.SetNotificationStatus', {'enabled': true});
} catch (e) {
_log('SetNotificationStatus: $e');
}
}
// Déconnexion
void disconnect() {
_heartbeatTimer?.cancel();
_simulationTimer?.cancel();
_socket?.destroy();
_socket = null;
_wsSub?.cancel();
_wsChannel?.sink.close();
_wsChannel = null;
_connected = false;
_connecting = false;
_isSimulation = false;
_buffer.clear();
for (final c in _pendingRequests.values) {
c.completeError(Exception('Disconnected'));
}
_pendingRequests.clear();
notifyListeners();
}
//
// JSON-RPC SEND \n pour TCP, frame WebSocket pour WS
//
Future<Map<String, dynamic>> _sendRequest(
String method, Map<String, dynamic> params) async {
if (_socket == null && _wsChannel == null) {
throw Exception('Not connected');
}
final id = _requestId++;
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[id] = completer;
final msg = jsonEncode({
'id': id, 'method': method, 'params': params,
'token': _token,
});
_log('→ [$id] $method', force: true, json: msg);
if (_protocol == NymeaProtocol.tcpRaw) {
_socket!.write('$msg\n');
} else {
_wsChannel!.sink.add(msg);
}
// Timeout 15s évite le blocage si nymea ne répond pas
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
_pendingRequests.remove(id);
throw TimeoutException('Request $method timed out', const Duration(seconds: 15));
},
);
}
//
// MESSAGE PROCESSING (commun TCP + WS)
//
void _processMessage(Map<String, dynamic> data) {
// Réponse à une requête
if (data.containsKey('id')) {
final id = data['id'];
// JSON decode peut retourner num au lieu de int on normalise
final intId = (id is num) ? id.toInt() : null;
if (intId != null && _pendingRequests.containsKey(intId)) {
_log('← [$intId] réponse status=${data['status'] ?? '?'}',
force: true, json: jsonEncode(data));
_pendingRequests[intId]!.complete(data);
_pendingRequests.remove(intId);
} else if (intId != null) {
_log('← [$intId] réponse non attendue (déjà timeout?)', force: true);
}
}
// Notification push
if (data.containsKey('notification')) {
final notification = data['notification'] as String;
_log('← push: $notification', force: true,
json: _verboseLog ? jsonEncode(data) : null);
_handleNotification(
notification,
data['params'] as Map<String, dynamic>? ?? {},
);
}
}
void _handleNotification(
String notification, Map<String, dynamic> params) {
_log('← push: $notification');
switch (notification) {
case 'Energy.PowerBalanceChanged':
_parsePowerBalance(params);
break;
case 'Energy.RootMeterChanged':
_parseRootMeter(params);
break;
case 'Integrations.StateChanged':
_handleStateChanged(params);
break;
case 'Integrations.ThingAdded':
final t = params['thing'];
if (t != null) {
_things.add(NymeaThing.fromJson(t as Map<String, dynamic>));
notifyListeners();
}
break;
case 'Integrations.ThingRemoved':
_things.removeWhere((t) => t.id == params['thingId']);
notifyListeners();
break;
}
}
void _handleStateChanged(Map<String, dynamic> params) {
final thingId = params['thingId'] as String?;
final stateTypeId = params['stateTypeId'] as String?;
final value = params['value'];
if (thingId == null || stateTypeId == null) return;
final idx = _things.indexWhere((t) => t.id == thingId);
if (idx >= 0) {
final states = List<NymeaStateValue>.from(_things[idx].states);
final si = states.indexWhere((s) => s.stateTypeId == stateTypeId);
if (si >= 0) {
states[si] = NymeaStateValue(stateTypeId: stateTypeId, value: value);
} else {
states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value));
}
_things[idx] = _things[idx].copyWith(states: states);
notifyListeners();
}
}
void _onError(dynamic error) {
_log('❌ Connection error: $error', force: true);
_connectionError = error.toString();
_connected = false;
for (final c in _pendingRequests.values) {
c.completeError(error);
}
_pendingRequests.clear();
notifyListeners();
}
void _onDone() {
_log('🔌 Connection closed', force: true);
_connected = false;
notifyListeners();
}
//
// HEARTBEAT & INITIAL LOAD
//
Future<void> _initialLoad() async {
await Future.wait([
_loadThings(),
_loadEnergyStatus(),
_loadEnergyHistory(),
]);
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
// Polling toutes les 5 secondes pour rafraîchir les données énergie
_heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) async {
if (!_connected || _isSimulation) return;
try {
await _loadEnergyStatus();
} catch (_) {
_connected = false;
notifyListeners();
}
});
}
//
// ENERGY MANAGER
//
Future<void> _loadEnergyStatus() async {
try {
final r = await _sendRequest('Energy.GetPowerBalance', {});
_log('📊 PowerBalance: $r');
_parsePowerBalance(r);
} catch (e) { _log('GetPowerBalance error: $e'); }
}
// nymea 1.14 Energy.PowerBalanceChanged / Energy.GetPowerBalance
// Champs réels confirmés depuis les logs :
// currentPowerProduction : W solaire (NÉGATIF! ex: -1190 = 1190W PV)
// currentPowerConsumption : W maison (positif)
// currentPowerAcquisition : W réseau (positif = import, 0 = export)
// currentPowerStorage : W batterie (+ charge, - décharge)
// totalProduction : kWh PV cumulé
// totalConsumption : kWh maison cumulé
// totalAcquisition : kWh réseau importé cumulé
// totalReturn : kWh réseau exporté cumulé
void _parsePowerBalance(Map<String, dynamic> response) {
final p = response['params'] as Map<String, dynamic>? ?? response;
_log('📊 Energy fields: ${p.keys.toList()}');
double? n(String k) => (p[k] as num?)?.toDouble();
// Production PV est négative dans nymea (convention producteur = négatif)
final rawProd = n('currentPowerProduction');
final pv = rawProd != null ? rawProd.abs() : 0.0;
final home = n('currentPowerConsumption') ?? 0.0;
final grid = n('currentPowerAcquisition') ?? 0.0; // + import, 0 si export
final bat = n('currentPowerStorage') ?? 0.0;
// Totaux cumulés en kWh convertir en Wh pour l'affichage
final totalProdKwh = n('totalProduction') ?? 0.0;
final totalRetKwh = n('totalReturn') ?? 0.0;
final totalConsoKwh = n('totalConsumption') ?? 0.0;
final totalAcqKwh = n('totalAcquisition') ?? 0.0;
// Autoconsommation = production - injection réseau
final selfConsoKwh = totalProdKwh - totalRetKwh;
// Taux autoconsommation = autoconso / production * 100
final selfRate = totalProdKwh > 0 ? (selfConsoKwh / totalProdKwh * 100).clamp(0.0, 100.0) : 0.0;
// Taux autonomie = (production + batterie utilisée) / conso * 100
final autoRate = totalConsoKwh > 0 ? ((totalConsoKwh - totalAcqKwh) / totalConsoKwh * 100).clamp(0.0, 100.0) : 0.0;
_energyData = _energyData.copyWith(
pvPower: pv,
homePower: home,
gridPower: grid,
batteryPower: bat,
dayProductionWh: totalProdKwh * 1000,
dayGridInjectionWh: totalRetKwh * 1000,
daySelfConsumptionWh: selfConsoKwh * 1000,
selfConsumptionRate: selfRate,
autonomyRate: autoRate,
dayGains: totalRetKwh * 0.13 + selfConsoKwh * 0.22, // estimation tarifaire
);
notifyListeners();
}
void _parseRootMeter(Map<String, dynamic> response) {
// Dans nymea 1.14, tout est dans PowerBalance méthode conservée pour compat
}
Future<void> refreshEnergy() => _loadEnergyStatus();
/// Rafraîchit things + classes + énergie
Future<void> refresh() async {
await Future.wait([_loadThings(), _loadEnergyStatus()]);
}
Future<void> _loadEnergyHistory() async {
try {
final now = DateTime.now();
final from = DateTime(now.year, now.month, now.day)
.millisecondsSinceEpoch ~/ 1000;
final to = now.millisecondsSinceEpoch ~/ 1000;
final r = await _sendRequest('Energy.GetEnergyLogs', {
'sampleRate': 'SampleRateHour',
'from': from,
'to': to,
});
final logs = r['params']?['logs'] as List? ?? [];
_historyPoints = logs.map((l) {
final lm = l as Map<String, dynamic>;
return HistoryPoint(
time: DateTime.fromMillisecondsSinceEpoch(
(lm['timestamp'] as int) * 1000),
pvWh: (lm['totalEnergyProduced'] as num?)?.toDouble() ?? 0,
homeWh: (lm['totalEnergyConsumed'] as num?)?.toDouble() ?? 0,
gridWh: (lm['totalEnergyReturned'] as num?)?.toDouble() ?? 0,
);
}).toList();
notifyListeners();
} catch (e) { _log('GetEnergyLogs: $e'); }
}
Future<void> setChargingMode(ChargingMode mode) async {
final modeStr = {
ChargingMode.pv: 'pv',
ChargingMode.minPv: 'minpv',
ChargingMode.boost: 'boost',
}[mode]!;
if (_connected && !_isSimulation) {
try {
await _sendRequest('Energy.SetChargingMode', {'mode': modeStr});
} catch (e) { _log('SetChargingMode: $e'); }
}
_energyData = _energyData.copyWith(chargingMode: mode);
notifyListeners();
}
//
// THINGS / INTEGRATIONS
//
bool _thingsLoaded = false;
bool get thingsLoaded => _thingsLoaded;
Future<void> _loadThings() async {
// Étape 1 : charger les things configurés
try {
_log('📦 GetThings → envoi...', force: true);
final r = await _sendRequest('Integrations.GetThings', {});
_log('📦 GetThings ← status=${r["status"]} keys=${r.keys.toList()}', force: true);
final thingsRaw = r['params']?['things']
?? r['result']?['things']
?? r['things'];
if (thingsRaw is List) {
_things = thingsRaw
.map((t) => NymeaThing.fromJson(t as Map<String, dynamic>))
.where((t) => t.id.isNotEmpty) // exclure entrées invalides
.toList();
_log('📦 ✅ ${_things.length} things configurés:', force: true);
for (final t in _things) {
_log(' 📱 "${t.name}" | classId=${t.thingClassId.substring(0, 8)}… | ${t.setupStatus}', force: true);
}
} else {
_log('📦 ⚠️ GetThings: clé "things" absente — keys=${r.keys.toList()}', force: true);
}
} catch (e, st) {
_log('📦 ❌ GetThings erreur: $e\n$st', force: true);
}
// Étape 2 : charger UNIQUEMENT les classes des things présents
// On filtre sur les thingClassIds réellement utilisés évite les 80+ plugins
if (_things.isNotEmpty) {
try {
final neededClassIds = _things
.map((t) => t.thingClassId)
.where((id) => id.isNotEmpty)
.toSet()
.toList();
_log('📦 GetThingClasses pour ${neededClassIds.length} classes...', force: true);
// nymea supporte un filtre optionnel thingClassIds
final r = await _sendRequest('Integrations.GetThingClasses', {
'thingClassIds': neededClassIds,
});
final classesRaw = r['params']?['thingClasses']
?? r['result']?['thingClasses']
?? r['thingClasses'];
if (classesRaw is List && classesRaw.isNotEmpty) {
_thingClasses = classesRaw
.map((c) => NymeaThingClass.fromJson(c as Map<String, dynamic>))
.toList();
_log('📦 ✅ ThingClasses: ${_thingClasses.length}', force: true);
for (final c in _thingClasses) {
_log(' 📋 "${c.name}" | id=${c.id.substring(0, 8)}… | interfaces=${c.interfaces}', force: true);
}
} else {
// Fallback : nymea < 1.6 ne supporte peut-être pas le filtre tout charger
_log('📦 Filtre non supporté, fallback GetThingClasses sans filtre...', force: true);
await _loadAllThingClassesFiltered(neededClassIds);
}
} catch (e) {
_log('📦 ❌ GetThingClasses erreur: $e', force: true);
// Fallback si le filtre cause une erreur
await _loadAllThingClassesFiltered(
_things.map((t) => t.thingClassId).toSet().toList()
);
}
}
_thingsLoaded = true;
notifyListeners();
}
/// Fallback : charge toutes les classes puis filtre côté client
Future<void> _loadAllThingClassesFiltered(List<String> neededIds) async {
try {
final r = await _sendRequest('Integrations.GetThingClasses', {});
final classesRaw = r['params']?['thingClasses']
?? r['result']?['thingClasses']
?? r['thingClasses'];
if (classesRaw is List) {
final all = classesRaw
.map((c) => NymeaThingClass.fromJson(c as Map<String, dynamic>))
.toList();
// Garder seulement les classes dont on a besoin
_thingClasses = all
.where((c) => neededIds.contains(c.id))
.toList();
_log('📦 Fallback: ${all.length} classes totales → ${_thingClasses.length} utilisées', force: true);
for (final c in _thingClasses) {
_log(' 📋 "${c.name}" interfaces=${c.interfaces}', force: true);
}
}
} catch (e) {
_log('📦 ❌ Fallback GetThingClasses erreur: $e', force: true);
}
}
Future<void> refreshThings() => _loadThings();
Future<List<Map<String, dynamic>>> discoverThings(String thingClassId) async {
if (_isSimulation) return [];
try {
final r = await _sendRequest(
'Integrations.DiscoverThings', {'thingClassId': thingClassId});
return List<Map<String, dynamic>>.from(
r['params']?['thingDescriptors'] ?? []);
} catch (_) { return []; }
}
Future<bool> addThing({
required String thingClassId,
required String thingDescriptorId,
required String name,
}) async {
if (_isSimulation) return false;
try {
final r = await _sendRequest('Integrations.AddThing', {
'thingClassId': thingClassId,
'name': name,
'thingDescriptorId': thingDescriptorId,
});
if (r['status'] == 'success') { await _loadThings(); return true; }
return false;
} catch (_) { return false; }
}
Future<bool> removeThing(String thingId) async {
if (_isSimulation) return false;
try {
final r = await _sendRequest(
'Integrations.RemoveThing', {'thingId': thingId});
if (r['status'] == 'success') {
_things.removeWhere((t) => t.id == thingId);
notifyListeners();
return true;
}
return false;
} catch (_) { return false; }
}
Future<bool> renameThing(String thingId, String newName) async {
if (_isSimulation) return false;
try {
final r = await _sendRequest(
'Integrations.EditThing', {'thingId': thingId, 'name': newName});
if (r['status'] == 'success') {
final idx = _things.indexWhere((t) => t.id == thingId);
if (idx >= 0) {
_things[idx] = _things[idx].copyWith(name: newName);
notifyListeners();
}
return true;
}
return false;
} catch (_) { return false; }
}
Future<bool> setThingSettings(
String thingId, String settingTypeId, dynamic value) async {
if (_isSimulation) return true;
try {
final r = await _sendRequest('Integrations.SetThingSettings', {
'thingId': thingId,
'settings': [{'paramTypeId': settingTypeId, 'value': value}],
});
return r['params']?['thingError'] == 'ThingErrorNoError';
} catch (e) {
_log('SetThingSettings: $e');
return false;
}
}
Future<bool> executeAction({
required String thingId,
required String actionTypeId,
Map<String, dynamic> params = const {},
}) async {
if (_isSimulation) return true;
try {
final r = await _sendRequest('Integrations.ExecuteAction', {
'thingId': thingId,
'actionTypeId': actionTypeId,
'params': params.entries
.map((e) => {'paramTypeId': e.key, 'value': e.value})
.toList(),
});
return r['status'] == 'success';
} catch (e) { _log('ExecuteAction: $e'); return false; }
}
Future<dynamic> getStateValue(String thingId, String stateTypeId) async {
if (_isSimulation) return null;
try {
final r = await _sendRequest('Integrations.GetStateValue', {
'thingId': thingId, 'stateTypeId': stateTypeId,
});
return r['params']?['value'];
} catch (_) { return null; }
}
Future<List<Map<String, dynamic>>> getRules() async {
if (_isSimulation) return [];
try {
final r = await _sendRequest('Rules.GetRules', {});
return List<Map<String, dynamic>>.from(r['params']?['rules'] ?? []);
} catch (_) { return []; }
}
//
// FAVORITES
//
void addFavorite(FavoriteWidget widget) {
if (!_favoriteWidgets.any((f) => f.id == widget.id)) {
_favoriteWidgets.add(widget);
notifyListeners();
}
}
void removeFavorite(String id) {
_favoriteWidgets.removeWhere((f) => f.id == id);
notifyListeners();
}
void reorderFavorites(int oldIndex, int newIndex) {
if (newIndex > oldIndex) newIndex--;
final item = _favoriteWidgets.removeAt(oldIndex);
_favoriteWidgets.insert(newIndex, item);
notifyListeners();
}
//
// SIMULATION
//
void startSimulation() {
disconnect();
_connected = true;
_isSimulation = true;
_nymeaVersion = 'demo 1.8.2';
_generateSimulatedThings();
_generateHistory();
if (_favoriteWidgets.isEmpty) {
_favoriteWidgets = [
FavoriteWidget(id: 'fw_pv', type: FavoriteType.pvPower, title: 'Production PV'),
FavoriteWidget(id: 'fw_home', type: FavoriteType.homePower, title: 'Consommation'),
FavoriteWidget(id: 'fw_soc', type: FavoriteType.batterySOC, title: 'Batterie'),
FavoriteWidget(id: 'fw_rates', type: FavoriteType.rates, title: 'Taux'),
FavoriteWidget(id: 'fw_ev', type: FavoriteType.evCharger, title: 'Borne EV'),
];
}
_simulationTimer?.cancel();
_simulationTimer = Timer.periodic(
const Duration(seconds: 2), (_) => _simulateData());
_simulateData();
notifyListeners();
}
void _simulateData() {
final now = DateTime.now();
final hour = now.hour + now.minute / 60.0;
final rng = Random();
double pv = 0;
if (hour >= 7 && hour <= 20) {
pv = (3800 * sin((hour - 7) * pi / 13) + rng.nextDouble() * 80 - 40)
.clamp(0, 4500);
}
final home = 200 + rng.nextDouble() * 600;
final battery = pv > home + 200 ? (pv - home - 200).clamp(0.0, 2000.0) : 0.0;
final batDischarge = home > pv + 100 && _energyData.batterySOC > 10
? min(home - pv, 1500.0) : 0.0;
final grid = home - pv - battery + batDischarge;
final soc = (_energyData.batterySOC +
(battery > 0 ? 0.1 : batDischarge > 0 ? -0.05 : 0)).clamp(5.0, 100.0);
_energyData = _energyData.copyWith(
pvPower: pv, homePower: home,
batteryPower: battery > 0 ? battery : (batDischarge > 0 ? -batDischarge : 0),
gridPower: grid, batterySOC: soc, temperature: 7,
dayProductionWh: pv * 0.5, daySelfConsumptionWh: home * 0.4,
dayGridInjectionWh: grid < 0 ? -grid * 0.3 : 0,
selfConsumptionRate: pv > 0 ? (min(pv, home) / pv * 100).clamp(0.0, 100.0) : 0.0,
autonomyRate: home > 0 ? ((pv + batDischarge) / home * 100).clamp(0.0, 100.0) : 0.0,
dayGains: (pv * 0.5 / 1000) * 0.13,
);
notifyListeners();
}
void _generateHistory() {
final now = DateTime.now();
_historyPoints = List.generate(24, (i) {
final h = i.toDouble();
final pv = (h >= 7 && h <= 20)
? (3000 * sin((h - 7) * pi / 13)).clamp(0.0, 4500.0) : 0.0;
return HistoryPoint(
time: DateTime(now.year, now.month, now.day, i),
pvWh: pv, homeWh: 200 + Random().nextDouble() * 400,
gridWh: (200 - pv * 0.3).clamp(0, 500),
);
});
}
void _generateSimulatedThings() {
_things = [
NymeaThing(id: 'sim-inverter', name: 'Onduleur SolarEdge', thingClassId: 'solaredge',
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
states: [NymeaStateValue(stateTypeId: 'power', value: 4200.0)]),
NymeaThing(id: 'sim-battery', name: 'Batterie BYD', thingClassId: 'byd',
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
states: [NymeaStateValue(stateTypeId: 'soc', value: 42.0)]),
NymeaThing(id: 'sim-ev', name: 'Borne Wallbox', thingClassId: 'wallbox',
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
states: [NymeaStateValue(stateTypeId: 'power', value: 3600.0)]),
NymeaThing(id: 'sim-meter', name: 'Compteur Linky', thingClassId: 'linky',
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
states: [NymeaStateValue(stateTypeId: 'power', value: 180.0)]),
NymeaThing(id: 'sim-ac', name: 'Climatiseur Salon', thingClassId: 'mitsubishi',
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
states: [NymeaStateValue(stateTypeId: 'power', value: false)]),
];
}
@override
void dispose() {
_heartbeatTimer?.cancel();
_simulationTimer?.cancel();
_socket?.destroy();
_wsSub?.cancel();
_wsChannel?.sink.close();
super.dispose();
}
}

36
lib/theme/app_theme.dart Normal file
View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class AppTheme {
// Colors
static const Color primaryGreen = Color(0xFF4CAF50);
static const Color solarYellow = Color(0xFFF5C842);
static const Color gridGray = Color(0xFF5A6473);
static const Color homeBlue = Color(0xFF5BA4CF);
static const Color batteryGreen = Color(0xFF4CAF50);
static const Color accentOrange = Color(0xFFFF7043);
static const Color backgroundGray = Color(0xFFF0F2F5);
static const Color cardWhite = Color(0xFFFFFFFF);
static const Color textDark = Color(0xFF1A1A2E);
static const Color textLight = Color(0xFF6B7280);
static const Color boostRed = Color(0xFFEF5350);
static const Color pvGreen = Color(0xFF43A047);
static const Color minPvBlue = Color(0xFF42A5F5);
static ThemeData get theme => ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: primaryGreen,
surface: backgroundGray,
),
scaffoldBackgroundColor: backgroundGray,
cardTheme: CardThemeData(
color: cardWhite,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
margin: EdgeInsets.zero,
),
fontFamily: 'Roboto',
useMaterial3: true,
);
}

View File

@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import '../models/energy_data.dart';
import '../theme/app_theme.dart';
class EnergyFlowWidget extends StatelessWidget {
final EnergyData data;
const EnergyFlowWidget({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Données en temps réel',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Row(
children: [
Text(
'${data.temperature.toStringAsFixed(0)}°C',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
),
const SizedBox(width: 4),
const Icon(Icons.cloud, color: Colors.blueGrey, size: 22),
],
),
],
),
const SizedBox(height: 20),
// Energy nodes
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_EnergyNode(
icon: Icons.wb_sunny_rounded,
color: AppTheme.solarYellow,
power: data.pvPower,
label: 'Solaire',
),
_EnergyNode(
icon: Icons.home_rounded,
color: AppTheme.homeBlue,
power: data.homePower,
label: 'Maison',
),
_EnergyNode(
icon: Icons.battery_charging_full_rounded,
color: AppTheme.batteryGreen,
power: data.batteryPower.abs(),
label: 'Batterie',
badge: '${data.batterySOC.toStringAsFixed(0)}%',
),
_EnergyNode(
icon: Icons.electrical_services_rounded,
color: AppTheme.gridGray,
power: data.gridPower.abs(),
label: data.gridPower >= 0 ? 'Réseau ↓' : 'Réseau ↑',
),
],
),
const SizedBox(height: 16),
// Flow diagram
SizedBox(
height: 80,
child: CustomPaint(
painter: _FlowPainter(data: data),
size: Size.infinite,
),
),
],
),
),
);
}
}
class _EnergyNode extends StatelessWidget {
final IconData icon;
final Color color;
final double power;
final String label;
final String? badge;
const _EnergyNode({
required this.icon,
required this.color,
required this.power,
required this.label,
this.badge,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 30),
),
if (badge != null)
Positioned(
bottom: -4,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1.5),
),
child: Text(
badge!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
),
),
),
],
),
const SizedBox(height: 10),
Text(
_formatPower(power),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Text(
_powerUnit(power),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textLight,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textLight,
),
),
],
);
}
String _formatPower(double w) {
if (w >= 1000) return (w / 1000).toStringAsFixed(1);
return w.toStringAsFixed(0);
}
String _powerUnit(double w) => w >= 1000 ? 'kW' : 'W';
}
class _FlowPainter extends CustomPainter {
final EnergyData data;
_FlowPainter({required this.data});
@override
void paint(Canvas canvas, Size size) {
// Draw animated flow lines between nodes
final centerY = size.height * 0.4;
final nodePositions = [
size.width * 0.125, // PV
size.width * 0.375, // Home
size.width * 0.625, // Battery
size.width * 0.875, // Grid
];
final linePaint = Paint()
..strokeWidth = 2
..style = PaintingStyle.stroke;
// PV -> Home (always if PV > 0)
if (data.pvPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[0], centerY),
Offset(nodePositions[1], centerY), AppTheme.solarYellow, linePaint);
}
// Home -> Battery (if battery charging)
if (data.batteryPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[1], centerY),
Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint);
}
// Grid -> Home (if importing)
if (data.gridPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[3], centerY),
Offset(nodePositions[1], centerY), Colors.orange, linePaint);
} else if (data.gridPower < 0) {
// Exporting
_drawArrowLine(canvas, Offset(nodePositions[2], centerY),
Offset(nodePositions[3], centerY), AppTheme.solarYellow, linePaint);
}
// Draw node dots
final dotPaint = Paint()..style = PaintingStyle.fill;
for (int i = 0; i < nodePositions.length; i++) {
final colors = [
AppTheme.solarYellow,
AppTheme.homeBlue,
AppTheme.batteryGreen,
AppTheme.gridGray,
];
dotPaint.color = colors[i];
canvas.drawCircle(Offset(nodePositions[i], centerY), 6, dotPaint);
}
}
void _drawArrowLine(Canvas canvas, Offset start, Offset end, Color color,
Paint paint) {
paint.color = color.withValues(alpha:0.7);
final path = Path()
..moveTo(start.dx, start.dy)
..lineTo(end.dx, end.dy);
canvas.drawPath(path, paint);
// Arrow head
final arrowPaint = Paint()
..color = color
..style = PaintingStyle.fill;
final dx = end.dx - start.dx;
final arrowSign = dx > 0 ? 1 : -1;
final arrowTip = Offset(end.dx - arrowSign * 8, end.dy);
final arrowPath = Path()
..moveTo(arrowTip.dx + arrowSign * 8, arrowTip.dy)
..lineTo(arrowTip.dx, arrowTip.dy - 5)
..lineTo(arrowTip.dx, arrowTip.dy + 5)
..close();
canvas.drawPath(arrowPath, arrowPaint);
}
@override
bool shouldRepaint(_FlowPainter oldDelegate) => true;
}

View File

@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import '../models/energy_data.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
class EVChargingCard extends StatelessWidget {
final EnergyData data;
final NymeaService service;
const EVChargingCard({
super.key,
required this.data,
required this.service,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Recharge du véhicule',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Text(
_statusLabel(data.chargingMode),
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight,
),
),
],
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _showSettings(context),
color: AppTheme.textLight,
),
],
),
const SizedBox(height: 16),
// Mode buttons
Row(
children: [
_ModeButton(
label: 'PV',
icon: Icons.wb_sunny_rounded,
color: AppTheme.pvGreen,
isSelected: data.chargingMode == ChargingMode.pv,
onTap: () => service.setChargingMode(ChargingMode.pv),
),
const SizedBox(width: 8),
_ModeButton(
label: 'Min + PV',
icon: Icons.bolt,
color: AppTheme.minPvBlue,
isSelected: data.chargingMode == ChargingMode.minPv,
onTap: () => service.setChargingMode(ChargingMode.minPv),
),
const SizedBox(width: 8),
_ModeButton(
label: 'Boost',
icon: Icons.rocket_launch_rounded,
color: AppTheme.boostRed,
isSelected: data.chargingMode == ChargingMode.boost,
onTap: () => service.setChargingMode(ChargingMode.boost),
),
],
),
const SizedBox(height: 16),
// Stats
_InfoRow(
label: 'Puissance borne',
value: '${data.chargingPower.toStringAsFixed(1)} kW',
),
const SizedBox(height: 6),
_InfoRow(
label: 'Source',
value: '${data.solarSourcePercent.toStringAsFixed(0)}% solaire',
valueColor: AppTheme.pvGreen,
),
const SizedBox(height: 6),
_InfoRow(
label: 'Limite réseau',
value: data.gridLimitOk ? 'OK ✓' : 'Dépassée !',
valueColor: data.gridLimitOk ? AppTheme.primaryGreen : AppTheme.boostRed,
),
// Charging indicator
const SizedBox(height: 12),
if (data.chargingMode != ChargingMode.pv || data.pvPower > 0)
_ChargingIndicator(
mode: data.chargingMode,
pvPower: data.pvPower,
chargingPower: data.chargingPower,
),
],
),
),
);
}
String _statusLabel(ChargingMode mode) {
switch (mode) {
case ChargingMode.pv:
return 'Surplus PV disponible';
case ChargingMode.minPv:
return 'Minimum + surplus PV';
case ChargingMode.boost:
return 'Charge rapide (réseau)';
}
}
void _showSettings(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _ChargingSettingsSheet(),
);
}
}
class _ModeButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool isSelected;
final VoidCallback onTap;
const _ModeButton({
required this.label,
required this.icon,
required this.color,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected ? color : color.withValues(alpha:0.1),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
color: isSelected ? Colors.white : color, size: 16),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
const _InfoRow({required this.label, required this.value, this.valueColor});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label,
style: const TextStyle(fontSize: 13, color: AppTheme.textLight)),
const Text(' : ', style: TextStyle(color: AppTheme.textLight)),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: valueColor ?? AppTheme.textDark,
),
),
],
);
}
}
class _ChargingIndicator extends StatelessWidget {
final ChargingMode mode;
final double pvPower;
final double chargingPower;
const _ChargingIndicator({
required this.mode,
required this.pvPower,
required this.chargingPower,
});
@override
Widget build(BuildContext context) {
final pvPercent = (pvPower / (chargingPower * 1000)).clamp(0.0, 1.0);
final gridPercent = mode == ChargingMode.boost ? 1.0 : (1 - pvPercent).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Répartition charge',
style: TextStyle(fontSize: 12, color: AppTheme.textLight)),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
height: 10,
child: Row(
children: [
Flexible(
flex: (pvPercent * 100).toInt(),
child: Container(color: AppTheme.solarYellow),
),
Flexible(
flex: (gridPercent * 100).toInt(),
child: Container(color: Colors.orange.shade300),
),
],
),
),
),
const SizedBox(height: 4),
Row(
children: [
_legend(AppTheme.solarYellow, 'Solaire'),
const SizedBox(width: 12),
_legend(Colors.orange.shade300, 'Réseau'),
],
),
],
);
}
Widget _legend(Color color, String label) => Row(
children: [
Container(width: 10, height: 10, color: color),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppTheme.textLight)),
],
);
}
class _ChargingSettingsSheet extends StatefulWidget {
const _ChargingSettingsSheet();
@override
State<_ChargingSettingsSheet> createState() => _ChargingSettingsSheetState();
}
class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> {
double _minPower = 6;
double _maxPower = 16;
bool _scheduleEnabled = false;
TimeOfDay _startTime = const TimeOfDay(hour: 22, minute: 0);
TimeOfDay _endTime = const TimeOfDay(hour: 6, minute: 0);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Paramètres de charge',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
const Text('Courant minimum (A)',
style: TextStyle(fontWeight: FontWeight.w500)),
Slider(
value: _minPower,
min: 6,
max: 16,
divisions: 10,
label: '${_minPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _minPower = v),
activeColor: AppTheme.primaryGreen,
),
const Text('Courant maximum (A)',
style: TextStyle(fontWeight: FontWeight.w500)),
Slider(
value: _maxPower,
min: 6,
max: 32,
divisions: 26,
label: '${_maxPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _maxPower = v),
activeColor: AppTheme.primaryGreen,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Programmation horaire',
style: TextStyle(fontWeight: FontWeight.w500)),
Switch(
value: _scheduleEnabled,
onChanged: (v) => setState(() => _scheduleEnabled = v),
activeThumbColor: AppTheme.primaryGreen,
),
],
),
if (_scheduleEnabled) ...[
Row(
children: [
Expanded(
child: _TimePicker(
label: 'Début',
time: _startTime,
onChanged: (t) => setState(() => _startTime = t),
),
),
const SizedBox(width: 16),
Expanded(
child: _TimePicker(
label: 'Fin',
time: _endTime,
onChanged: (t) => setState(() => _endTime = t),
),
),
],
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text('Enregistrer'),
),
),
],
),
);
}
}
class _TimePicker extends StatelessWidget {
final String label;
final TimeOfDay time;
final ValueChanged<TimeOfDay> onChanged;
const _TimePicker({
required this.label,
required this.time,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: time);
if (picked != null) onChanged(picked);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.access_time, size: 16, color: AppTheme.textLight),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: AppTheme.textLight)),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../models/energy_data.dart';
import '../theme/app_theme.dart';
class GainsCard extends StatelessWidget {
final EnergyData data;
const GainsCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.amber.shade50,
shape: BoxShape.circle,
),
child: const Icon(Icons.euro_rounded,
color: Colors.amber, size: 22),
),
const SizedBox(width: 12),
const Text(
'Gains',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
),
],
),
Row(
children: [
Text(
'${data.dayGains.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
const Icon(Icons.chevron_right, color: AppTheme.textLight),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';
import '../models/energy_data.dart';
import '../theme/app_theme.dart';
class ProductionCard extends StatelessWidget {
final EnergyData data;
const ProductionCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Production du jour',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Text(
'${_formatEnergy(data.dayProductionWh)} Wh',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
],
),
CircularPercentIndicator(
radius: 35,
lineWidth: 6,
percent: (data.selfConsumptionRate / 100).clamp(0, 1),
center: Text(
'${data.selfConsumptionRate.toStringAsFixed(0)}%',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
progressColor: AppTheme.primaryGreen,
backgroundColor: Colors.grey.shade200,
),
],
),
const Divider(height: 24),
// Self-consumption
_StatRow(
icon: Icons.home_rounded,
iconColor: AppTheme.solarYellow,
label: 'Usage personnel',
value: '${_formatEnergy(data.daySelfConsumptionWh)} Wh',
subLabel: 'Autoconsommation',
percent: data.selfConsumptionRate,
color: AppTheme.solarYellow,
),
const SizedBox(height: 12),
// Grid injection
_StatRow(
icon: Icons.electrical_services_rounded,
iconColor: AppTheme.gridGray,
label: 'Vers réseau',
value: '${_formatEnergy(data.dayGridInjectionWh)} Wh',
subLabel: 'Autonomie',
percent: data.autonomyRate,
color: AppTheme.homeBlue,
),
],
),
),
);
}
String _formatEnergy(double wh) {
if (wh >= 1000) return '${(wh / 1000).toStringAsFixed(2)} k';
return wh.toStringAsFixed(0);
}
}
class _StatRow extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String label;
final String value;
final String subLabel;
final double percent;
final Color color;
const _StatRow({
required this.icon,
required this.iconColor,
required this.label,
required this.value,
required this.subLabel,
required this.percent,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha:0.15),
shape: BoxShape.circle,
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: const TextStyle(
fontSize: 14, color: AppTheme.textLight)),
Text(value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textDark)),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'$subLabel : ${percent.toStringAsFixed(0)}%',
style: TextStyle(fontSize: 11, color: color),
),
const SizedBox(width: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (percent / 100).clamp(0, 1),
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation(color),
minHeight: 5,
),
),
),
],
),
],
),
),
],
),
],
);
}
}

1
linux/.gitignore vendored Normal file
View File

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

128
linux/CMakeLists.txt Normal file
View File

@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "etm_powersync_app")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.etm_powersync_app")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

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

View File

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

View File

@ -0,0 +1,23 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

6
linux/runner/main.cc Normal file
View File

@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "etm_powersync_app");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "etm_powersync_app");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}

View File

@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

7
macos/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View File

@ -0,0 +1 @@
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "ephemeral/Flutter-Generated.xcconfig"

View File

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

View File

@ -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 = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* etm_powersync_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "etm_powersync_app.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
/* 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 = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* etm_powersync_app.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 /* etm_powersync_app.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 = "<group>";
};
/* 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.etmPowersyncApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/etm_powersync_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/etm_powersync_app";
};
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.etmPowersyncApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/etm_powersync_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/etm_powersync_app";
};
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.etmPowersyncApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/etm_powersync_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/etm_powersync_app";
};
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 */;
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "etm_powersync_app.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "etm_powersync_app.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C80D4294CF70F00263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "etm_powersync_app.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "etm_powersync_app.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

Some files were not shown because too many files have changed in this diff Show More