Release version 1.0.0

This commit is contained in:
2025-09-16 21:48:33 +02:00
commit a6d27c5f21
165 changed files with 8385 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
name: Build and Release APK
on:
push:
tags:
- "v*.*.*" # Runs only when a version tag is pushed
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install jq
run: apt-get update && apt-get install -y jq
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: "3.29.3"
- name: Mark Flutter SDK as safe
run: |
git config --global --add safe.directory /opt/hostedtoolcache/flutter/stable-3.29.3-x64
- name: Install dependencies
run: flutter pub get
- name: Build APK
run: flutter build apk --release
- name: Create Gitea release
env:
TOKEN_GITEA: ${{ secrets.TOKEN_GITEA }}
TAG_NAME: ${{ github.ref_name }}
run: |
curl -X POST https://git.filiprojek.cz/api/v1/repos/fr/android_fuelstats/releases \
-H "Authorization: token $TOKEN_GITEA" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"$TAG_NAME"'",
"name": "'"$TAG_NAME"'",
"body": "Automated release for version '"$TAG_NAME"'",
"draft": false,
"prerelease": false
}'
- name: Upload APK to release
env:
TOKEN_GITEA: ${{ secrets.TOKEN_GITEA }}
TAG_NAME: ${{ github.ref_name }}
run: |
# Get release ID
RELEASE_ID=$(curl -s -H "Authorization: token $TOKEN_GITEA" \
https://git.filiprojek.cz/api/v1/repos/fr/android_fuelstats/releases/tags/$TAG_NAME \
| jq -r '.id')
curl -X POST \
-H "Authorization: token $TOKEN_GITEA" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
https://git.filiprojek.cz/api/v1/repos/fr/android_fuelstats/releases/$RELEASE_ID/assets

43
.github/workflows/build-profile.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Build Profile APK
on:
push:
branches: ["dev"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Determine version
run: |
VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //')
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
- name: Install dependencies
run: flutter pub get
- name: Build profile APK
run: flutter build apk --profile --build-name="$VERSION_NAME" --build-number="$BUILD_NUMBER"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: profile-apk
path: build/app/outputs/flutter-apk/app-profile.apk

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# 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

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

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# Fuel Stats
Fuel Stats is a Flutter application for tracking vehicle fuel consumption and service history. It lets you record refuels and maintenance, manage multiple vehicles, and visualise trends in cost and efficiency.
## Features
- Email-based authentication with login and sign-up flows
- Manage vehicles and choose a default one
- Log refuels with liters, price, mileage and notes
- Record maintenance/service events, including cost and optional photos
- Stats dashboard with consumption figures and kilometers driven
- Charts for gas price and fuel consumption trends
## Platform Support
The app is tested only on Android. A web build should also work, but iOS, macOS, Windows, and Linux have not been tested.
## Configuration
The app communicates with a Node.js backend maintained in a separate project: [Fuel Stats Server](https://github.com/filiprojek/fuelstats-server).
Provide the server's base URL at build time using a compile-time define:
```bash
flutter run --dart-define=API_BASE_URL=https://api.example.com
```
## Getting Started
1. Install [Flutter](https://flutter.dev) (3.7 or later).
2. Fetch dependencies:
```bash
flutter pub get
```
3. Launch the application:
```bash
flutter run --dart-define=API_BASE_URL=http://localhost:6060
```
## CI/CD
A GitHub Actions workflow builds a profile APK on each push to the `dev` branch. The workflow reads the version from `pubspec.yaml`, uses the repository commit count as the build number, and uploads the APK as an artifact.

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,46 @@
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 = "cz.filiprojek.fuelstats"
compileSdk = flutter.compileSdkVersion
//ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "cz.filiprojek.fuelstats"
// 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,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="Fuel Stats" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">
<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>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,5 @@
package cz.filiprojek.fuelstats
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</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>

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

@@ -0,0 +1,21 @@
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,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=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.10.2-all.zip

View File

@@ -0,0 +1,25 @@
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.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
}
include(":app")

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

1
assets/icon/app_icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><style>.a{fill:#042449}.b{fill:#99aab5}.c{fill:#ffac33}.d{fill:#aab8c2}.e{fill:#be1931}.f{fill:#e1e8ed}.g{fill:#66757f}.h{fill:none;stroke:#aab8c2;stroke-linecap:round;stroke-linejoin:round;stroke-width:50}.i{fill:#e1e8ed;stroke:#aab8c2;stroke-linecap:round;stroke-linejoin:round;stroke-width:50}</style><path fill-rule="evenodd" class="a" d="m1024 0v1024h-1024v-1024z"/><path class="b" d="m483.9 788.7c-35.3 0-73-24.3-73-92.3 0-38.2 16.3-64.1 32.1-89.2 6.2-9.8 12.4-19.7 18.1-30.7-22.7-26.2-47.7-46.1-64.8-46.1v-36.8c23.7 0 52.6 20.9 78.6 49.2 5.5-18.4 9-40.2 9-67.7h29.2c0 40-6.6 70.4-15.8 95 21.1 28.4 37.8 59.6 44.2 83.6 10 38.1 7 78-7.8 104.1-11.4 19.9-29.1 30.9-49.8 30.9zm-1.9-185.4c-5.4 9.7-10.9 18.6-16.1 26.9-14.4 22.8-25.8 40.9-25.8 66.2 0 45.8 23.8 55.4 43.8 55.4 11.4 0 20.2-5.3 26.1-15.6 9.3-16.4 10.8-44.2 3.8-70.8-4.8-18.1-16.8-40.6-31.8-62.1z"/><path class="c" d="m356.3 364.4h-178.8c-32.2 0-56.6 33.1-54.5 73.8l17 332c2.1 40.8 25.9 73.8 53.2 73.8h147.4c27.2 0 51-33 53.2-73.8l17-332c2.1-40.7-22.3-73.8-54.5-73.8z"/><path class="d" d="m425.5 788.7h-320.9c-1.9 0-3.8 0.4-5.6 1.4-1.8 0.9-3.4 2.3-4.7 4-1.4 1.7-2.5 3.7-3.2 6-0.7 2.2-1.1 4.6-1.1 7v36.9h350.1v-36.9c0-2.4-0.4-4.8-1.1-7-0.7-2.3-1.8-4.3-3.2-6-1.3-1.7-2.9-3.1-4.7-4-1.8-1-3.7-1.4-5.6-1.4z"/><path class="c" d="m498.5 364.4c-2 0-3.9-0.4-5.6-1.4-1.8-0.9-3.4-2.2-4.8-4-1.3-1.7-2.4-3.7-3.1-5.9-0.7-2.3-1.1-4.7-1.1-7.1v-59.7l30.7-77.7c1.7-4.3 4.8-7.7 8.4-9.2 3.7-1.5 7.7-1.2 11.2 1 3.4 2.2 6 6 7.3 10.7 1.2 4.6 0.9 9.7-0.8 14.1l-27.6 69.8v51c0 2.4-0.4 4.8-1.2 7.1-0.7 2.2-1.8 4.2-3.1 5.9-1.4 1.8-3 3.1-4.8 4-1.7 1-3.6 1.4-5.5 1.4z"/><path class="e" d="m534.9 475.1h-11.3c2.5-5.4 4-11.7 4-18.4v-73.8c0-13.6-5.9-25.4-14.5-31.8v-5.1c0-4.9-1.6-9.6-4.3-13-2.8-3.5-6.5-5.4-10.3-5.4-3.9 0-7.6 1.9-10.4 5.4-2.7 3.4-4.2 8.1-4.2 13v5.1c-2.2 1.6-4.3 3.6-6.1 5.8-1.8 2.3-3.3 4.8-4.6 7.6-1.3 2.8-2.2 5.8-2.9 8.9-0.7 3.1-1 6.3-1 9.5v73.8c0 6.7 1.5 13 4 18.4h-11.3c-1.9 0-3.8 1-5.2 2.7-1.3 1.7-2.1 4.1-2.1 6.5 0 2.5 0.8 4.8 2.1 6.6 1.4 1.7 3.3 2.7 5.2 2.7h72.9c2 0 3.8-1 5.2-2.7 1.4-1.8 2.1-4.1 2.1-6.6 0-2.4-0.7-4.8-2.1-6.5-1.4-1.7-3.2-2.7-5.2-2.7z"/><path class="b" d="m410.9 438.2c0 2.4 0.4 4.8 1.1 7.1 0.8 2.2 1.9 4.3 3.2 6 1.4 1.7 3 3 4.7 4 1.8 0.9 3.7 1.4 5.6 1.4h73c3.8 0 7.5-2 10.3-5.4 2.7-3.5 4.3-8.2 4.3-13.1 0-4.9-1.6-9.6-4.3-13-2.8-3.5-6.5-5.4-10.3-5.4h-73c-1.9 0-3.8 0.5-5.6 1.4-1.7 0.9-3.3 2.3-4.7 4-1.3 1.7-2.4 3.7-3.2 6-0.7 2.2-1.1 4.6-1.1 7z"/><path class="f" d="m440.1 438.2c0 9.7-1.5 19.3-4.4 28.3-3 8.9-7.3 17-12.7 23.9-5.4 6.8-11.8 12.3-18.9 16-7.1 3.7-14.7 5.6-22.3 5.6h-233.4c-7.7 0-15.3-1.9-22.4-5.6-7.1-3.7-13.5-9.2-18.9-16-5.4-6.9-9.7-15-12.7-23.9-2.9-9-4.4-18.6-4.4-28.3v-184.4c0-9.7 1.5-19.3 4.4-28.3 3-8.9 7.3-17 12.7-23.9 5.4-6.8 11.8-12.3 18.9-16 7.1-3.7 14.7-5.6 22.4-5.6h233.4c7.6 0 15.2 1.9 22.3 5.6 7.1 3.7 13.5 9.2 18.9 16 5.4 6.9 9.7 15 12.7 23.9 2.9 9 4.4 18.6 4.4 28.3z"/><path class="g" d="m410.9 438.2c0 4.9-0.7 9.7-2.2 14.1-1.5 4.5-3.6 8.6-6.3 12-2.7 3.4-5.9 6.1-9.5 8-3.5 1.9-7.3 2.8-11.1 2.8h-233.4c-3.9 0-7.7-0.9-11.2-2.8-3.6-1.9-6.8-4.6-9.5-8-2.7-3.4-4.8-7.5-6.3-12-1.5-4.4-2.2-9.2-2.2-14.1v-184.4c0-4.9 0.7-9.7 2.2-14.1 1.5-4.5 3.6-8.6 6.3-12 2.7-3.4 5.9-6.1 9.5-8 3.5-1.9 7.3-2.8 11.2-2.8h233.4c3.8 0 7.6 0.9 11.1 2.8 3.6 1.9 6.8 4.6 9.5 8 2.7 3.4 4.8 7.5 6.3 12 1.5 4.4 2.2 9.2 2.2 14.1z"/><path class="d" d="m367.2 272.2v55.4h-131.3v-55.4z"/><path class="h" d="m603.7 815.3h346.6"/><path class="i" d="m738 450.1v365.2h78v-365.2c0-22.4-7.8-40.6-31.2-40.6h-15.6c-23.4 0-31.2 18.2-31.2 40.6z"/><path class="i" d="m621 571.8v243.5h69.3v-243.5c0-22.3-6.9-40.6-27.7-40.6h-13.9c-20.8 0-27.7 18.3-27.7 40.6z"/><path class="i" d="m863.7 673.3v142h69.3v-142c0-22.3-6.9-40.6-27.7-40.6h-13.9c-20.8 0-27.7 18.3-27.7 40.6z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

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,26 @@
<?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>
<key>MinimumOSVersion</key>
<string>12.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,616 @@
// !$*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 */; };
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>"; };
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 */,
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 */,
);
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 = 12.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 = cz.filiprojek.fuelstats;
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 = cz.filiprojek.fuelstats.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 = cz.filiprojek.fuelstats.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 = cz.filiprojek.fuelstats.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 = AppIcon;
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 = 12.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 = AppIcon;
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 = 12.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 = cz.filiprojek.fuelstats;
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 = cz.filiprojek.fuelstats;
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,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 = "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"
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"
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,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1 @@
{"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":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"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":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@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: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 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>

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

@@ -0,0 +1,49 @@
<?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>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Fuel Stats</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>Fuel Stats</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>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>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

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

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

2
lib/config.dart Normal file
View File

@@ -0,0 +1,2 @@
const String apiBaseUrl =
String.fromEnvironment('API_BASE_URL', defaultValue: 'https://fuelstats.filiprojek.cz');

164
lib/main.dart Normal file
View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/session_manager.dart';
import 'screens/home_screen.dart';
import 'screens/add_screen.dart';
import 'screens/vehicles_screen.dart';
import 'screens/history_screen.dart';
import 'screens/user_settings.dart';
import 'screens/login.dart';
import 'screens/signup.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SessionManager().init();
runApp(
ChangeNotifierProvider(
create: (_) => SessionManager(),
child: FuelStatsApp(),
),
);
}
class FuelStatsApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(),
themeMode: ThemeMode.dark,
debugShowCheckedModeBanner: false,
home: MainNavigation(),
);
}
}
class MainNavigation extends StatefulWidget {
const MainNavigation({super.key});
@override
_MainNavigationState createState() => _MainNavigationState();
}
class _MainNavigationState extends State<MainNavigation> {
int _currentIndex = 0;
bool get isAuthScreen => _currentIndex == 5 || _currentIndex == 6;
Widget get currentTitle {
switch (_currentIndex) {
case 0:
return Text("Fuel Stats");
case 1:
return Text("Add record");
case 2:
return Text("Vehicles");
case 3:
return Text("History");
case 4:
return Text("Settings");
case 5:
return Text(""); // Empty title on login
case 6:
return Text(""); // Empty title on signup
default:
return Text("Fuel Stats");
}
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
// Auto-redirect to login if not logged in and not already on auth screens
Future.delayed(Duration(milliseconds: 100), () {
if (!session.isLoggedIn && !isAuthScreen) {
setState(() => _currentIndex = 5);
}
});
final screens = [
HomeScreen(),
AddScreen(
onSaved: () {
setState(() => _currentIndex = 0);
},
),
VehiclesScreen(),
HistoryScreen(),
UserSettingsScreen(
onLogout: () {
setState(() => _currentIndex = 5); // Go to login
},
),
LoginScreen(
onSwitchToSignup: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 6);
});
},
onLoginSuccess: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 0); // Go to Home
});
},
),
SignupScreen(
onSwitchToLogin: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 5);
});
},
onSignupSuccess: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 0); // Go to Home
});
},
),
];
return Scaffold(
appBar: AppBar(
title: currentTitle,
actions: !isAuthScreen
? [
IconButton(
icon: const Icon(Icons.person),
tooltip: "User settings",
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserSettingsScreen(
onLogout: () {
Navigator.pop(context); // Close settings
setState(() => _currentIndex = 5); // Go to Login
},
),
),
);
},
),
]
: null,
),
body: screens[_currentIndex],
bottomNavigationBar: !isAuthScreen
? BottomNavigationBar(
currentIndex: _currentIndex <= 3 ? _currentIndex : 0,
onTap: (index) => setState(() => _currentIndex = index),
backgroundColor: Colors.grey[900],
selectedItemColor: Colors.white,
unselectedItemColor: Colors.grey,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.add), label: 'Add'),
BottomNavigationBarItem(icon: Icon(Icons.directions_car), label: 'Vehicles'),
BottomNavigationBarItem(icon: Icon(Icons.history), label: 'History'),
],
)
: null,
);
}
}

20
lib/main.dart.old2 Normal file
View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(home: Home()));
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Fuel Stats"),
backgroundColor: Colors.blue[700],
),
body: Column(children: [Text("vim")]),
);
}
}

113
lib/models/refuel.dart Normal file
View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'vehicle.dart';
class Refuel {
final String? id;
final String vehicleId;
final FuelType fuelType;
final double liters;
final double pricePerLiter;
final double totalPrice;
final int mileage;
final String? note;
final DateTime? createdAt;
Refuel({
this.id,
required this.vehicleId,
required this.fuelType,
required this.liters,
required this.pricePerLiter,
required this.totalPrice,
required this.mileage,
this.note,
this.createdAt,
});
Refuel copyWith({
String? id,
String? vehicleId,
FuelType? fuelType,
double? liters,
double? pricePerLiter,
double? totalPrice,
int? mileage,
String? note,
DateTime? createdAt,
}) {
return Refuel(
id: id ?? this.id,
vehicleId: vehicleId ?? this.vehicleId,
fuelType: fuelType ?? this.fuelType,
liters: liters ?? this.liters,
pricePerLiter: pricePerLiter ?? this.pricePerLiter,
totalPrice: totalPrice ?? this.totalPrice,
mileage: mileage ?? this.mileage,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toApiMap() {
return {
'vehicleId': vehicleId,
'fuelType': fuelType.name,
'note': note,
'liters': liters,
'pricePerLiter': pricePerLiter,
'totalPrice': totalPrice,
'mileage': mileage,
};
}
factory Refuel.fromApi(Map<String, dynamic> map) {
return Refuel(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
fuelType:
FuelType.values.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
liters: (map['liters'] as num).toDouble(),
pricePerLiter: (map['pricePerLiter'] as num).toDouble(),
totalPrice: (map['totalPrice'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'vehicleId': vehicleId,
'fuelType': fuelType.name,
'note': note,
'liters': liters,
'pricePerLiter': pricePerLiter,
'totalPrice': totalPrice,
'mileage': mileage,
'createdAt': createdAt?.toIso8601String(),
};
factory Refuel.fromMap(Map<String, dynamic> map) {
return Refuel(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
fuelType:
FuelType.values.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
liters: (map['liters'] as num).toDouble(),
pricePerLiter: (map['pricePerLiter'] as num).toDouble(),
totalPrice: (map['totalPrice'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
String toJson() => jsonEncode(toMap());
factory Refuel.fromJson(String source) =>
Refuel.fromMap(jsonDecode(source) as Map<String, dynamic>);
}

235
lib/models/service.dart Normal file
View File

@@ -0,0 +1,235 @@
import 'dart:convert';
class ServiceRecord {
final String? id;
final String vehicleId;
final ServiceType serviceType;
final String? customType;
final String? itemName;
final double cost;
final int mileage;
final String? shop;
final bool selfService;
final String? note;
final List<String> photos;
final DateTime? date;
final DateTime? createdAt;
ServiceRecord({
this.id,
required this.vehicleId,
required this.serviceType,
this.customType,
this.itemName,
required this.cost,
required this.mileage,
this.shop,
this.selfService = false,
this.note,
List<String>? photos,
this.date,
this.createdAt,
}) : photos = photos ?? [];
String get displayType =>
serviceType == ServiceType.other && customType != null && customType!.isNotEmpty
? customType!
: serviceType.label;
ServiceRecord copyWith({
String? id,
String? vehicleId,
ServiceType? serviceType,
String? customType,
String? itemName,
double? cost,
int? mileage,
String? shop,
bool? selfService,
String? note,
List<String>? photos,
DateTime? date,
DateTime? createdAt,
}) {
return ServiceRecord(
id: id ?? this.id,
vehicleId: vehicleId ?? this.vehicleId,
serviceType: serviceType ?? this.serviceType,
customType: customType ?? this.customType,
itemName: itemName ?? this.itemName,
cost: cost ?? this.cost,
mileage: mileage ?? this.mileage,
shop: shop ?? this.shop,
selfService: selfService ?? this.selfService,
note: note ?? this.note,
photos: photos ?? List.of(this.photos),
date: date ?? this.date,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toApiMap() {
return {
'vehicleId': vehicleId,
'serviceType': serviceType.apiValue,
if (customType != null && customType!.isNotEmpty) 'customType': customType,
if (itemName != null) 'itemName': itemName,
'cost': cost,
'mileage': mileage,
if (shop != null && shop!.isNotEmpty) 'shop': shop,
if (selfService) 'selfService': true,
if (note != null) 'note': note,
'photos': photos,
if (date != null) 'date': date!.toUtc().toIso8601String(),
};
}
factory ServiceRecord.fromApi(Map<String, dynamic> map) {
return ServiceRecord(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
serviceType: ServiceTypeX.fromApi(map['serviceType'] as String),
customType: map['customType'] as String?,
itemName: map['itemName'] as String?,
cost: (map['cost'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
shop: map['shop'] as String?,
selfService: map['selfService'] as bool? ?? false,
note: map['note'] as String?,
photos: map['photos'] != null
? List<String>.from(map['photos'] as List)
: [],
date: map['date'] != null
? DateTime.tryParse(map['date'] as String)
: null,
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'vehicleId': vehicleId,
'serviceType': serviceType.apiValue,
'customType': customType,
'itemName': itemName,
'cost': cost,
'mileage': mileage,
'shop': shop,
'selfService': selfService,
'note': note,
'photos': photos,
'date': date?.toIso8601String(),
'createdAt': createdAt?.toIso8601String(),
};
factory ServiceRecord.fromMap(Map<String, dynamic> map) {
return ServiceRecord(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
serviceType: ServiceTypeX.fromApi(map['serviceType'] as String),
customType: map['customType'] as String?,
itemName: map['itemName'] as String?,
cost: (map['cost'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
shop: map['shop'] as String?,
selfService: map['selfService'] as bool? ?? false,
note: map['note'] as String?,
photos: map['photos'] != null
? List<String>.from(map['photos'] as List)
: [],
date: map['date'] != null
? DateTime.tryParse(map['date'] as String)
: null,
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
String toJson() => jsonEncode(toMap());
factory ServiceRecord.fromJson(String source) =>
ServiceRecord.fromMap(jsonDecode(source) as Map<String, dynamic>);
}
enum ServiceType {
airFilter,
oilFilter,
fuelFilter,
cabinFilter,
motorOil,
brakePadFront,
brakePadRear,
sparkPlug,
coolant,
tireChange,
battery,
other,
}
extension ServiceTypeX on ServiceType {
String get label {
switch (this) {
case ServiceType.airFilter:
return 'Air Filter';
case ServiceType.oilFilter:
return 'Oil Filter';
case ServiceType.fuelFilter:
return 'Fuel Filter';
case ServiceType.cabinFilter:
return 'Cabin Filter';
case ServiceType.motorOil:
return 'Motor Oil';
case ServiceType.brakePadFront:
return 'Brake Pads (Front)';
case ServiceType.brakePadRear:
return 'Brake Pads (Rear)';
case ServiceType.sparkPlug:
return 'Spark Plugs';
case ServiceType.coolant:
return 'Coolant';
case ServiceType.tireChange:
return 'Tire Change';
case ServiceType.battery:
return 'Battery';
case ServiceType.other:
return 'Other';
}
}
String get apiValue {
switch (this) {
case ServiceType.airFilter:
return 'air_filter';
case ServiceType.oilFilter:
return 'oil_filter';
case ServiceType.fuelFilter:
return 'fuel_filter';
case ServiceType.cabinFilter:
return 'cabin_filter';
case ServiceType.motorOil:
return 'motor_oil';
case ServiceType.brakePadFront:
return 'brake_pad_front';
case ServiceType.brakePadRear:
return 'brake_pad_rear';
case ServiceType.sparkPlug:
return 'spark_plug';
case ServiceType.coolant:
return 'coolant';
case ServiceType.tireChange:
return 'tire_change';
case ServiceType.battery:
return 'battery';
case ServiceType.other:
return 'other';
}
}
static ServiceType fromApi(String value) {
return ServiceType.values.firstWhere(
(e) => e.apiValue == value,
orElse: () => ServiceType.other);
}
}

104
lib/models/vehicle.dart Normal file
View File

@@ -0,0 +1,104 @@
import 'dart:convert';
enum FuelType { diesel, gasoline95, gasoline98, other }
extension FuelTypeX on FuelType {
String get label {
switch (this) {
case FuelType.diesel:
return 'Diesel';
case FuelType.gasoline95:
return 'Gasoline 95';
case FuelType.gasoline98:
return 'Gasoline 98';
case FuelType.other:
return 'Other';
}
}
static FuelType fromIndex(int index) => FuelType.values[index];
}
class Vehicle {
final String? id;
final String name;
final String registrationPlate;
final FuelType fuelType;
final String? note;
final bool isDefault;
Vehicle({
this.id,
required this.name,
required this.registrationPlate,
required this.fuelType,
this.note,
this.isDefault = false,
});
Vehicle copyWith({
String? id,
String? name,
String? registrationPlate,
FuelType? fuelType,
String? note,
bool? isDefault,
}) {
return Vehicle(
id: id ?? this.id,
name: name ?? this.name,
registrationPlate: registrationPlate ?? this.registrationPlate,
fuelType: fuelType ?? this.fuelType,
note: note ?? this.note,
isDefault: isDefault ?? this.isDefault,
);
}
Map<String, dynamic> toApiMap() {
return {
'name': name,
'registrationPlate': registrationPlate,
'fuelType': fuelType.name,
'note': note,
'isDefault': isDefault,
};
}
factory Vehicle.fromApi(Map<String, dynamic> map) {
return Vehicle(
id: map['id'] as String?,
name: map['name'] as String,
registrationPlate: map['registrationPlate'] as String,
fuelType: FuelType.values
.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
isDefault: map['isDefault'] as bool? ?? false,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'name': name,
'registrationPlate': registrationPlate,
'fuelType': fuelType.index,
'note': note,
'isDefault': isDefault,
};
factory Vehicle.fromMap(Map<String, dynamic> map) {
return Vehicle(
id: map['id'] as String?,
name: map['name'] as String,
registrationPlate: map['registrationPlate'] as String,
fuelType: map['fuelType'] is int
? FuelTypeX.fromIndex(map['fuelType'] as int)
: FuelType.values
.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
isDefault: map['isDefault'] as bool? ?? false,
);
}
String toJson() => jsonEncode(toMap());
factory Vehicle.fromJson(String source) => Vehicle.fromMap(jsonDecode(source));
}

279
lib/screens/add_screen.dart Normal file
View File

@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../models/refuel.dart';
import '../services/session_manager.dart';
import 'add_service_screen.dart';
class AddScreen extends StatefulWidget {
final Refuel? refuel;
final VoidCallback? onSaved;
final bool standalone;
AddScreen({this.refuel, this.onSaved, this.standalone = false});
@override
_AddScreenState createState() => _AddScreenState();
}
class _AddScreenState extends State<AddScreen> {
final _formKey = GlobalKey<FormState>();
String? _selectedVehicleId;
FuelType? _selectedFuelType;
final TextEditingController _litersController = TextEditingController();
final TextEditingController _pricePerLiterController =
TextEditingController();
final TextEditingController _totalPriceController = TextEditingController();
final TextEditingController _mileageController = TextEditingController();
final TextEditingController _noteController = TextEditingController();
final FocusNode _litersFocus = FocusNode();
final FocusNode _pricePerLiterFocus = FocusNode();
final FocusNode _totalPriceFocus = FocusNode();
bool _initialized = false;
bool _isRecalculating = false;
@override
void initState() {
super.initState();
_litersController.addListener(_recalculate);
_pricePerLiterController.addListener(_recalculate);
_totalPriceController.addListener(_recalculate);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
final session = Provider.of<SessionManager>(context);
if (widget.refuel != null) {
final r = widget.refuel!;
_selectedVehicleId = r.vehicleId;
_selectedFuelType = r.fuelType;
_litersController.text = r.liters.toString();
_pricePerLiterController.text = r.pricePerLiter.toString();
_totalPriceController.text = r.totalPrice.toString();
_mileageController.text = r.mileage.toString();
if (r.note != null) _noteController.text = r.note!;
} else {
_selectedVehicleId = session.defaultVehicle?.id;
_selectedFuelType = session.defaultVehicle?.fuelType;
}
_initialized = true;
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
final form = Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Vehicle'),
value: _selectedVehicleId,
items: vehicles
.map(
(vehicle) => DropdownMenuItem(
value: vehicle.id,
child: Text(vehicle.name),
),
)
.toList(),
onChanged: (value) {
setState(() {
_selectedVehicleId = value;
final vehicle = vehicles.firstWhere((v) => v.id == value);
_selectedFuelType = vehicle.fuelType;
});
},
validator:
(value) => value == null ? 'Please select a vehicle' : null,
),
SizedBox(height: 16),
DropdownButtonFormField<FuelType>(
decoration: InputDecoration(labelText: 'Fuel Type'),
value: _selectedFuelType,
items: FuelType.values
.map((fuel) => DropdownMenuItem(
value: fuel,
child: Text(fuel.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedFuelType = value),
validator:
(value) => value == null ? 'Please select a fuel type' : null,
),
SizedBox(height: 16),
TextFormField(
controller: _litersController,
focusNode: _litersFocus,
decoration: InputDecoration(labelText: 'Liters'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _pricePerLiterController,
focusNode: _pricePerLiterFocus,
decoration: InputDecoration(labelText: 'Price per Liter'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _totalPriceController,
focusNode: _totalPriceFocus,
decoration: InputDecoration(labelText: 'Total Price'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _mileageController,
decoration: InputDecoration(labelText: 'Mileage'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note'),
keyboardType: TextInputType.text,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
child: Text(widget.refuel == null
? 'Create Fuel Record'
: 'Update Fuel Record'),
),
if (!widget.standalone) ...[
SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddServiceScreen(standalone: true),
),
);
},
icon: Icon(Icons.build),
label: Text('Add Service Record'),
),
],
],
),
),
),
);
if (widget.standalone) {
return Scaffold(
appBar: AppBar(
title: Text(widget.refuel == null
? 'Add Refuel Record'
: 'Edit Refuel Record'),
),
body: form,
);
}
return form;
}
String? _numberValidator(String? value) {
if (value == null || value.isEmpty) return 'This field cannot be empty';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
void _recalculate() {
if (_isRecalculating) return;
final liters = double.tryParse(_litersController.text);
final price = double.tryParse(_pricePerLiterController.text);
final total = double.tryParse(_totalPriceController.text);
_isRecalculating = true;
if (!_totalPriceFocus.hasFocus && liters != null && price != null) {
_totalPriceController.text = (liters * price).toStringAsFixed(2);
} else if (!_pricePerLiterFocus.hasFocus && liters != null && total != null) {
_pricePerLiterController.text = (total / liters).toStringAsFixed(2);
} else if (!_litersFocus.hasFocus && price != null && total != null) {
_litersController.text = (total / price).toStringAsFixed(2);
}
_isRecalculating = false;
}
void _submitForm() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final session = Provider.of<SessionManager>(context, listen: false);
final refuel = Refuel(
id: widget.refuel?.id,
vehicleId: _selectedVehicleId!,
fuelType: _selectedFuelType!,
liters: double.parse(_litersController.text),
pricePerLiter: double.parse(_pricePerLiterController.text),
totalPrice: double.parse(_totalPriceController.text),
mileage: int.parse(_mileageController.text),
note: _noteController.text.isEmpty ? null : _noteController.text,
);
if (widget.refuel == null) {
await session.addRefuel(refuel);
} else if (widget.refuel!.id != null) {
await session.updateRefuel(widget.refuel!.id!, refuel);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fuel record saved successfully!')),
);
_formKey.currentState?.reset();
setState(() {
_selectedVehicleId = session.defaultVehicle?.id;
_selectedFuelType = session.defaultVehicle?.fuelType;
});
_litersController.clear();
_pricePerLiterController.clear();
_totalPriceController.clear();
_mileageController.clear();
_noteController.clear();
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
widget.onSaved?.call();
}
}
@override
void dispose() {
_litersController.dispose();
_pricePerLiterController.dispose();
_totalPriceController.dispose();
_mileageController.dispose();
_noteController.dispose();
_litersFocus.dispose();
_pricePerLiterFocus.dispose();
_totalPriceFocus.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,371 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/service.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
enum _ServicePerformer { shop, self }
class AddServiceScreen extends StatefulWidget {
final ServiceRecord? service;
final VoidCallback? onSaved;
final bool standalone;
AddServiceScreen({this.service, this.onSaved, this.standalone = false});
@override
_AddServiceScreenState createState() => _AddServiceScreenState();
}
class _AddServiceScreenState extends State<AddServiceScreen> {
final _formKey = GlobalKey<FormState>();
String? _selectedVehicleId;
ServiceType? _selectedType;
final TextEditingController _customTypeController = TextEditingController();
final TextEditingController _itemNameController = TextEditingController();
final TextEditingController _costController = TextEditingController();
final TextEditingController _mileageController = TextEditingController();
final TextEditingController _shopController = TextEditingController();
_ServicePerformer _performer = _ServicePerformer.shop;
final TextEditingController _noteController = TextEditingController();
final List<Uint8List> _photos = [];
DateTime? _selectedDate;
final TextEditingController _dateController = TextEditingController();
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
final session = Provider.of<SessionManager>(context);
if (widget.service != null) {
final s = widget.service!;
_selectedVehicleId = s.vehicleId;
_selectedType = s.serviceType;
if (s.customType != null) _customTypeController.text = s.customType!;
if (s.itemName != null) _itemNameController.text = s.itemName!;
_costController.text = s.cost.toString();
_mileageController.text = s.mileage.toString();
if (s.selfService) {
_performer = _ServicePerformer.self;
} else {
_performer = _ServicePerformer.shop;
if (s.shop != null) _shopController.text = s.shop!;
}
if (s.note != null) _noteController.text = s.note!;
for (final p in s.photos) {
try {
final data = p.contains(',') ? p.split(',').last : p;
_photos.add(base64Decode(data));
} catch (_) {}
}
_selectedDate = s.date;
if (s.date != null) {
_dateController.text = _formatDate(s.date!);
}
} else {
_selectedVehicleId = session.defaultVehicle?.id;
}
_initialized = true;
}
String _formatDate(DateTime date) {
final d = date.toLocal();
return '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
final form = Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Vehicle'),
value: _selectedVehicleId,
items: vehicles
.map((vehicle) => DropdownMenuItem(
value: vehicle.id,
child: Text(vehicle.name),
))
.toList(),
onChanged: (value) => setState(() => _selectedVehicleId = value),
validator: (value) =>
value == null ? 'Please select a vehicle' : null,
),
SizedBox(height: 16),
DropdownButtonFormField<ServiceType>(
decoration: InputDecoration(labelText: 'Service Type'),
value: _selectedType,
items: ServiceType.values
.map((t) => DropdownMenuItem(
value: t,
child: Text(t.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedType = value),
validator: (value) =>
value == null ? 'Please select a service type' : null,
),
if (_selectedType == ServiceType.other) ...[
SizedBox(height: 16),
TextFormField(
controller: _customTypeController,
decoration: InputDecoration(labelText: 'Custom Type'),
validator: (v) =>
v == null || v.isEmpty ? 'Enter custom type' : null,
),
],
SizedBox(height: 16),
TextFormField(
controller: _itemNameController,
decoration: InputDecoration(labelText: 'Item Name'),
),
SizedBox(height: 16),
TextFormField(
controller: _costController,
decoration: InputDecoration(labelText: 'Cost'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _mileageController,
decoration: InputDecoration(labelText: 'Mileage'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
RadioListTile<_ServicePerformer>(
value: _ServicePerformer.shop,
groupValue: _performer,
title: Text('Performed at shop'),
onChanged: (v) => setState(() => _performer = v!),
),
RadioListTile<_ServicePerformer>(
value: _ServicePerformer.self,
groupValue: _performer,
title: Text('Self Service'),
onChanged: (v) => setState(() {
_performer = v!;
_shopController.clear();
}),
),
if (_performer == _ServicePerformer.shop) ...[
SizedBox(height: 16),
TextFormField(
controller: _shopController,
decoration: InputDecoration(labelText: 'Shop'),
validator: (v) =>
v == null || v.isEmpty ? 'Enter shop name' : null,
),
],
SizedBox(height: 16),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note'),
),
if (_photos.isNotEmpty) ...[
SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_photos.length, (i) {
return Stack(
children: [
Image.memory(
_photos[i],
width: 80,
height: 80,
fit: BoxFit.cover,
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () => setState(() => _photos.removeAt(i)),
child: Container(
color: Colors.black54,
child: Icon(Icons.close, color: Colors.white, size: 20),
),
),
),
],
);
}),
),
],
SizedBox(height: 16),
TextButton.icon(
onPressed: _pickPhoto,
icon: Icon(Icons.add_a_photo),
label: Text('Add Photo'),
),
SizedBox(height: 16),
TextFormField(
controller: _dateController,
decoration: InputDecoration(labelText: 'Date'),
readOnly: true,
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? now,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
_selectedDate = picked;
_dateController.text = _formatDate(picked);
});
}
},
validator: (v) => _selectedDate == null ? 'Select a date' : null,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
child: Text(widget.service == null
? 'Create Service Record'
: 'Update Service Record'),
),
],
),
),
),
);
if (widget.standalone) {
return Scaffold(
appBar: AppBar(
title: Text(widget.service == null
? 'Add Service Record'
: 'Edit Service Record'),
),
body: form,
);
}
return form;
}
Future<void> _pickPhoto() async {
final picker = ImagePicker();
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Take Photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: Icon(Icons.photo_library),
title: Text('Choose from Gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
final file = await picker.pickImage(source: source);
if (file == null) return;
var bytes = await file.readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded != null) {
const maxDim = 800;
img.Image resized;
if (decoded.width > decoded.height) {
resized = img.copyResize(decoded, width: maxDim);
} else {
resized = img.copyResize(decoded, height: maxDim);
}
bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 70));
}
setState(() => _photos.add(bytes));
}
String? _numberValidator(String? value) {
if (value == null || value.isEmpty) return 'This field cannot be empty';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
Future<void> _submitForm() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final session = Provider.of<SessionManager>(context, listen: false);
final service = ServiceRecord(
id: widget.service?.id,
vehicleId: _selectedVehicleId!,
serviceType: _selectedType!,
customType: _selectedType == ServiceType.other
? _customTypeController.text
: null,
itemName: _itemNameController.text.isEmpty
? null
: _itemNameController.text,
cost: double.parse(_costController.text),
mileage: int.parse(_mileageController.text),
shop: _performer == _ServicePerformer.shop ? _shopController.text : null,
selfService: _performer == _ServicePerformer.self,
note: _noteController.text.isEmpty ? null : _noteController.text,
photos: _photos.map((b) => base64Encode(b)).toList(),
date: _selectedDate,
);
bool success = false;
if (widget.service == null) {
success = await session.addService(service);
} else if (widget.service!.id != null) {
success = await session.updateService(widget.service!.id!, service);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Service record saved successfully!'
: 'Failed to save service record.')),
);
if (success) {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
widget.onSaved?.call();
}
}
}
@override
void dispose() {
_customTypeController.dispose();
_itemNameController.dispose();
_costController.dispose();
_mileageController.dispose();
_shopController.dispose();
_noteController.dispose();
_dateController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,428 @@
import 'dart:ui';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/refuel.dart';
import '../models/service.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
import 'add_screen.dart';
import 'add_service_screen.dart';
import '../config.dart';
class HistoryScreen extends StatefulWidget {
@override
_HistoryScreenState createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
final TextEditingController _searchController = TextEditingController();
String _filter = 'all';
String _search = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
String _formatNumber(num value) {
if (value % 1 == 0) {
return value.toInt().toString();
}
return value
.toStringAsFixed(2)
.replaceFirst(RegExp(r'0+$'), '')
.replaceFirst(RegExp(r'[.]$'), '');
}
String _formatCurrency(num value) => '${_formatNumber(value)},-';
String _formatDateTime(DateTime? date) {
if (date == null) return '';
final d = date.toLocal();
final day = d.day.toString().padLeft(2, '0');
final month = d.month.toString().padLeft(2, '0');
final hour = d.hour.toString().padLeft(2, '0');
final minute = d.minute.toString().padLeft(2, '0');
return '$day.$month.${d.year} $hour:$minute';
}
TableRow _detailRow(String label, String value) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 2, right: 16),
child: Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(value),
),
],
);
}
void _showFullImage(BuildContext context, String p) {
Widget img;
if (p.startsWith('http') || p.startsWith('/')) {
final url = p.startsWith('http') ? p : '$apiBaseUrl$p';
img = Image.network(url);
} else {
try {
img = Image.memory(base64Decode(p));
} catch (_) {
return;
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: () => Navigator.pop(context),
child: Center(child: InteractiveViewer(child: img)),
),
),
),
);
}
bool _matchesSearch(Refuel? r, ServiceRecord? s) {
final q = _search.toLowerCase();
if (q.isEmpty) return true;
if (r != null) {
return (r.note ?? '').toLowerCase().contains(q) ||
r.fuelType.label.toLowerCase().contains(q);
} else if (s != null) {
return s.displayType.toLowerCase().contains(q) ||
(s.note ?? '').toLowerCase().contains(q) ||
(s.shop ?? '').toLowerCase().contains(q);
}
return false;
}
@override
Widget build(BuildContext context) {
return Consumer<SessionManager>(
builder: (context, session, _) {
final items = <_HistoryItem>[];
for (final r in session.refuels) {
items.add(_HistoryItem(date: r.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0), refuel: r));
}
for (final s in session.services) {
items.add(_HistoryItem(date: s.date ?? s.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0), service: s));
}
items.sort((a, b) => b.date.compareTo(a.date));
final filtered = items.where((i) {
if (_filter == 'refuel' && i.refuel == null) return false;
if (_filter == 'service' && i.service == null) return false;
return _matchesSearch(i.refuel, i.service);
}).toList();
return Scaffold(
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search...',
),
onChanged: (v) => setState(() => _search = v),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 8),
ChoiceChip(
label: Text('All'),
selected: _filter == 'all',
onSelected: (_) => setState(() => _filter = 'all'),
),
const SizedBox(width: 8),
ChoiceChip(
label: Text('Refuels'),
selected: _filter == 'refuel',
onSelected: (_) => setState(() => _filter = 'refuel'),
),
const SizedBox(width: 8),
ChoiceChip(
label: Text('Services'),
selected: _filter == 'service',
onSelected: (_) => setState(() => _filter = 'service'),
),
const SizedBox(width: 8),
],
),
),
Expanded(
child: filtered.isEmpty
? Center(child: Text('No history yet'))
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final item = filtered[index];
if (item.refuel != null) {
final r = item.refuel!;
final textStyle = const TextStyle(
fontFeatures: [FontFeature.tabularFigures()]);
return ListTile(
leading: Icon(Icons.local_gas_station, color: Colors.green),
title: Row(
children: [
Expanded(
child: Text(
'${_formatNumber(r.liters)} L',
style: textStyle,
),
),
Expanded(
child: Text(
'${_formatCurrency(r.pricePerLiter)}/L',
textAlign: TextAlign.center,
style: textStyle,
),
),
Expanded(
child: Text(
_formatCurrency(r.totalPrice),
textAlign: TextAlign.end,
style: textStyle,
),
),
],
),
subtitle: Text(_formatDateTime(r.createdAt), style: textStyle),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Refuel Details'),
content: Table(
columnWidths: {0: IntrinsicColumnWidth()},
children: [
_detailRow('Total', _formatCurrency(r.totalPrice)),
_detailRow('Price/L', '${_formatCurrency(r.pricePerLiter)}'),
_detailRow('Liters', '${_formatNumber(r.liters)} L'),
_detailRow('Mileage', '${_formatNumber(r.mileage)} km'),
if (r.note != null && r.note!.isNotEmpty)
_detailRow('Note', r.note!),
_detailRow('Date', _formatDateTime(r.createdAt)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Close'),
),
],
),
);
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddScreen(refuel: r, standalone: true),
),
);
},
),
if (r.id != null)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Deletion'),
content: Text('Are you sure you want to delete this record?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeRefuel(r.id!);
}
},
),
],
),
);
} else {
final s = item.service!;
final textStyle = const TextStyle(
fontFeatures: [FontFeature.tabularFigures()]);
return ListTile(
leading: Icon(Icons.build, color: Colors.orangeAccent),
title: Row(
children: [
Expanded(
child: Text(
s.displayType,
style: textStyle,
),
),
Expanded(
child: Text(
_formatCurrency(s.cost),
textAlign: TextAlign.end,
style: textStyle,
),
),
],
),
subtitle: Text(_formatDateTime(s.date), style: textStyle),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Service Details'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Table(
columnWidths: {0: IntrinsicColumnWidth()},
children: [
_detailRow('Type', s.displayType),
_detailRow('Cost', _formatCurrency(s.cost)),
_detailRow('Mileage', '${_formatNumber(s.mileage)} km'),
if (s.shop != null && s.shop!.isNotEmpty)
_detailRow('Shop', s.shop!),
_detailRow('Self service', s.selfService ? 'Yes' : 'No'),
if (s.note != null && s.note!.isNotEmpty)
_detailRow('Note', s.note!),
_detailRow('Date', _formatDateTime(s.date)),
],
),
if (s.photos.isNotEmpty) ...[
SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: s.photos.map((p) {
Widget img;
if (p.startsWith('http') || p.startsWith('/')) {
final url =
p.startsWith('http') ? p : '$apiBaseUrl$p';
img = Image.network(
url,
width: 100,
height: 100,
fit: BoxFit.cover,
);
} else {
try {
img = Image.memory(
base64Decode(p),
width: 100,
height: 100,
fit: BoxFit.cover,
);
} catch (_) {
img = SizedBox.shrink();
}
}
return GestureDetector(
onTap: () => _showFullImage(context, p),
child: img,
);
}).toList(),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Close'),
),
],
),
);
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddServiceScreen(service: s, standalone: true),
),
);
},
),
if (s.id != null)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Deletion'),
content: Text('Are you sure you want to delete this record?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeService(s.id!);
}
},
),
],
),
);
}
},
),
),
],
),
);
},
);
}
}
class _HistoryItem {
final DateTime date;
final Refuel? refuel;
final ServiceRecord? service;
_HistoryItem({required this.date, this.refuel, this.service});
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/refuel.dart';
import '../services/session_manager.dart';
import '../widgets/stat_card.dart';
import '../widgets/gas_price_chart.dart';
import '../widgets/consumption_chart.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
String _formatDouble(double value) {
var s = value.toStringAsFixed(2);
if (s.endsWith('.00')) {
s = s.substring(0, s.length - 3);
} else if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return s;
}
double? _allTimeConsumption(List<Refuel> refuels) {
if (refuels.length < 2) return null;
final distance = refuels.last.mileage - refuels.first.mileage;
if (distance <= 0) return null;
final liters =
refuels.skip(1).fold<double>(0.0, (sum, r) => sum + r.liters);
return liters / distance * 100;
}
double? _lastConsumption(List<Refuel> refuels) {
if (refuels.length < 2) return null;
final last = refuels[refuels.length - 1];
final prev = refuels[refuels.length - 2];
final distance = last.mileage - prev.mileage;
if (distance <= 0) return null;
return last.liters / distance * 100;
}
int? _kmSinceLast(List<Refuel> refuels) {
if (refuels.length < 2) return null;
return refuels.last.mileage - refuels[refuels.length - 2].mileage;
}
int _kmForPeriod(List<Refuel> refuels, DateTime from) {
final list = refuels
.where((r) => r.createdAt != null && r.createdAt!.isAfter(from))
.toList();
if (list.length < 2) return 0;
return list.last.mileage - list.first.mileage;
}
int _kmAllTime(List<Refuel> refuels) {
if (refuels.length < 2) return 0;
return refuels.last.mileage - refuels.first.mileage;
}
@override
Widget build(BuildContext context) {
return Consumer<SessionManager>(
builder: (context, session, _) {
final vehicle = session.defaultVehicle;
if (vehicle == null) {
return const Scaffold(
body: Center(child: Text('No default vehicle selected')),
);
}
final refuels = session.refuels
.where((r) => r.vehicleId == vehicle.id)
.toList();
refuels.sort((a, b) => a.mileage.compareTo(b.mileage));
final allCons = _allTimeConsumption(refuels);
final lastCons = _lastConsumption(refuels);
final kmLast = _kmSinceLast(refuels);
final now = DateTime.now();
final km1m = _kmForPeriod(refuels, now.subtract(const Duration(days: 30)));
final km6m =
_kmForPeriod(refuels, now.subtract(const Duration(days: 182)));
final km1y =
_kmForPeriod(refuels, now.subtract(const Duration(days: 365)));
final kmAll = _kmAllTime(refuels);
final lastRefuels =
refuels.length > 14 ? refuels.sublist(refuels.length - 14) : refuels;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.directions_car),
title: Text(vehicle.name,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(vehicle.registrationPlate),
trailing:
const Icon(Icons.star, color: Colors.amber),
),
),
const SizedBox(height: 16),
const Text('Refuel stats',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2,
children: [
StatCard(
title: 'Avg consumption (all time)',
value: allCons != null
? '${_formatDouble(allCons)} L/100km'
: '-',
),
StatCard(
title: 'Since last refuel',
value: lastCons != null
? '${_formatDouble(lastCons)} L/100km'
: '-',
),
],
),
const SizedBox(height: 24),
const Text('Kilometers driven',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Builder(builder: (context) {
final kmCards = <Widget>[
StatCard(
title: 'Since last refuel',
value: kmLast != null ? '$kmLast km' : '-',
),
StatCard(title: 'Past month', value: '$km1m km'),
StatCard(title: 'Past 6 months', value: '$km6m km'),
StatCard(title: 'Past year', value: '$km1y km'),
StatCard(title: 'All time', value: '$kmAll km'),
];
if (kmCards.length.isOdd) {
kmCards.add(const SizedBox.shrink());
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2,
children: kmCards,
);
}),
if (lastRefuels.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Gas price (last ${lastRefuels.length} refuels)',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: GasPriceChart(refuels: lastRefuels),
),
if (lastRefuels.length > 1) ...[
const SizedBox(height: 24),
const Text('Consumption trend',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: ConsumptionChart(refuels: lastRefuels),
),
],
],
const SizedBox(height: 8),
],
),
),
),
),
);
},
);
}
}

152
lib/screens/login.dart Normal file
View File

@@ -0,0 +1,152 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../config.dart';
import '../services/session_manager.dart';
class LoginScreen extends StatefulWidget {
final VoidCallback onSwitchToSignup;
final VoidCallback onLoginSuccess;
const LoginScreen({
required this.onSwitchToSignup,
required this.onLoginSuccess,
super.key,
});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Future<void> _login() async {
if (_formKey.currentState!.validate()) {
final email = _emailController.text;
final password = _passwordController.text;
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signin'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final token = data['token'];
final name = data['user']?['username'] ?? data['username'];
await Provider.of<SessionManager>(context, listen: false).login(
token: token,
email: email,
name: name,
);
widget.onLoginSuccess();
} else {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Login failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login error: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 40),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icon/app_icon.png',
width: 100,
height: 100,
),
),
const SizedBox(height: 16),
Text(
'Log in to Fuel Stats',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter your email';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter your password';
if (value.length < 6)
return 'Password must be at least 6 characters';
return null;
},
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _login,
icon: Icon(Icons.login),
label: Text('Log In'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onSwitchToSignup,
child: Text("Don't have an account? Sign up"),
),
],
),
),
),
),
);
}
}

186
lib/screens/signup.dart Normal file
View File

@@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../config.dart';
import '../services/session_manager.dart';
class SignupScreen extends StatefulWidget {
final VoidCallback onSwitchToLogin;
final VoidCallback onSignupSuccess;
const SignupScreen({
required this.onSwitchToLogin,
required this.onSignupSuccess,
super.key,
});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Future<void> _signup() async {
if (_formKey.currentState!.validate()) {
final username = _usernameController.text;
final email = _emailController.text;
final password = _passwordController.text;
try {
final signupResponse = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signup'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username,
'email': email,
'password': password,
}),
);
if (signupResponse.statusCode == 200 || signupResponse.statusCode == 201) {
final signinResponse = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signin'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (signinResponse.statusCode == 200) {
final data = jsonDecode(signinResponse.body);
final token = data['token'];
final name = data['user']?['username'] ?? data['username'] ?? username;
await Provider.of<SessionManager>(context, listen: false).login(
token: token,
email: email,
name: name,
);
widget.onSignupSuccess();
} else {
final data = jsonDecode(signinResponse.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Sign in failed')),
);
}
} else {
final data = jsonDecode(signupResponse.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Signup failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Signup error: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
//appBar: AppBar(title: Text('Sign Up')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 40),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icon/app_icon.png',
width: 100,
height: 100,
),
),
const SizedBox(height: 16),
Text(
'Create your Fuel Stats account',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter a username';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter an email';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter a password';
if (value.length < 6)
return 'Password must be at least 6 characters';
return null;
},
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _signup,
icon: Icon(Icons.person_add),
label: Text('Sign Up'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onSwitchToLogin,
child: Text("Already have an account? Log in"),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
class UserSettingsScreen extends StatelessWidget {
final VoidCallback onLogout;
const UserSettingsScreen({required this.onLogout, super.key});
Future<String> _getVersion() async {
final info = await PackageInfo.fromPlatform();
return 'Version: ${info.version}+${info.buildNumber}';
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final userName = session.name ?? "Unknown User"; // fallback just in case
final userEmail = session.email ?? '';
return Scaffold(
appBar: AppBar(title: Text('User settings')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
userName,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
if (userEmail.isNotEmpty)
Text(
userEmail,
style: TextStyle(fontSize: 16),
),
SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () async {
await session.logout();
onLogout();
},
icon: Icon(Icons.logout),
label: Text("Sign Out"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
),
),
SizedBox(height: 8),
FutureBuilder<String>(
future: _getVersion(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text(
snapshot.data ?? '',
style: TextStyle(color: Colors.grey),
);
} else {
return SizedBox.shrink();
}
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
class VehiclesScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
return Scaffold(
body: vehicles.isEmpty
? Center(child: Text('No vehicles added yet.'))
: ListView.builder(
itemCount: vehicles.length,
itemBuilder: (context, index) {
final vehicle = vehicles[index];
final isDefault = vehicle.isDefault;
return ListTile(
tileColor: isDefault
? Theme.of(context).colorScheme.secondaryContainer
: null,
leading: IconButton(
icon: Icon(Icons.star,
color: isDefault ? Colors.amber : Colors.grey),
tooltip: isDefault ? 'Unset default' : 'Set as default',
onPressed: () => session.setDefaultVehicle(
isDefault ? null : vehicle.id),
),
title: Text(vehicle.name),
subtitle: Text(
'${vehicle.registrationPlate}${vehicle.fuelType.label}'
'${vehicle.note != null ? '${vehicle.note}' : ''}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () => _editVehicle(context, session, index, vehicle),
),
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeVehicle(context, session, index),
),
],
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _addVehicle(context, session),
child: Icon(Icons.add),
tooltip: 'Add Vehicle',
),
);
}
void _addVehicle(BuildContext context, SessionManager session) async {
final newVehicle = await showDialog<Vehicle>(
context: context,
builder: (context) => _VehicleDialog(),
);
if (newVehicle != null) {
await session.addVehicle(newVehicle);
}
}
void _editVehicle(BuildContext context, SessionManager session, int index,
Vehicle vehicle) async {
final updatedVehicle = await showDialog<Vehicle>(
context: context,
builder: (context) => _VehicleDialog(vehicle: vehicle),
);
if (updatedVehicle != null) {
await session.updateVehicle(index, updatedVehicle);
}
}
void _removeVehicle(
BuildContext context, SessionManager session, int index) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Removal'),
content: Text(
'Are you sure you want to delete this vehicle? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeVehicle(index);
}
}
}
class _VehicleDialog extends StatefulWidget {
final Vehicle? vehicle;
const _VehicleDialog({this.vehicle});
@override
_VehicleDialogState createState() => _VehicleDialogState();
}
class _VehicleDialogState extends State<_VehicleDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _plateController;
late TextEditingController _noteController;
FuelType? _selectedFuelType;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.vehicle?.name ?? '');
_plateController =
TextEditingController(text: widget.vehicle?.registrationPlate ?? '');
_noteController = TextEditingController(text: widget.vehicle?.note ?? '');
_selectedFuelType = widget.vehicle?.fuelType;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.vehicle == null ? 'Add New Vehicle' : 'Edit Vehicle'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Vehicle Name'),
validator: (value) =>
value == null || value.isEmpty ? 'Enter a vehicle name' : null,
),
TextFormField(
controller: _plateController,
decoration: InputDecoration(labelText: 'Registration Plate'),
validator: (value) =>
value == null || value.isEmpty ? 'Enter a plate number' : null,
),
DropdownButtonFormField<FuelType>(
decoration: InputDecoration(labelText: 'Fuel Type'),
value: _selectedFuelType,
items: FuelType.values
.map((fuelType) => DropdownMenuItem(
value: fuelType,
child: Text(fuelType.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedFuelType = value),
validator: (value) =>
value == null ? 'Please select a fuel type' : null,
),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note (optional)'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
final vehicle = Vehicle(
id: widget.vehicle?.id,
name: _nameController.text,
registrationPlate: _plateController.text,
fuelType: _selectedFuelType!,
note:
_noteController.text.isEmpty ? null : _noteController.text,
isDefault: widget.vehicle?.isDefault ?? false,
);
Navigator.pop(context, vehicle);
}
},
child: Text(widget.vehicle == null ? 'Add' : 'Save'),
),
],
);
}
}

View File

@@ -0,0 +1,342 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../models/vehicle.dart';
import '../models/refuel.dart';
import '../models/service.dart';
class SessionManager extends ChangeNotifier {
static final SessionManager _instance = SessionManager._internal();
factory SessionManager() => _instance;
SessionManager._internal();
bool _loggedIn = false;
String? _token;
String? _email;
String? _name;
List<Vehicle> _vehicles = [];
List<Refuel> _refuels = [];
List<ServiceRecord> _services = [];
bool get isLoggedIn => _loggedIn;
String? get token => _token;
String? get email => _email;
String? get name => _name;
List<Vehicle> get vehicles => List.unmodifiable(_vehicles);
List<Refuel> get refuels => List.unmodifiable(_refuels);
List<ServiceRecord> get services => List.unmodifiable(_services);
Vehicle? get defaultVehicle {
try {
return _vehicles.firstWhere((v) => v.isDefault);
} catch (_) {
return null;
}
}
final _prefs = SharedPreferencesAsync(); // ✅ New API
Future<void> init() async {
_token = await _prefs.getString('token');
_email = await _prefs.getString('email');
_name = await _prefs.getString('name');
if (_token != null) {
final valid = await _validateToken();
if (valid) {
_loggedIn = true;
await fetchVehicles();
await fetchRefuels();
await fetchServices();
notifyListeners();
} else {
await logout();
}
} else {
_loggedIn = false;
notifyListeners();
}
}
Future<void> login({
required String token,
required String email,
String? name,
}) async {
await _prefs.setString('token', token);
await _prefs.setString('email', email);
if (name != null) await _prefs.setString('name', name);
_token = token;
_email = email;
_name = name;
_loggedIn = true;
// Ensure we have the latest user info and that the token is valid
await _validateToken();
await fetchVehicles();
await fetchRefuels();
await fetchServices();
notifyListeners();
}
Future<void> logout() async {
await _prefs.remove('token');
await _prefs.remove('email');
await _prefs.remove('name');
_token = null;
_email = null;
_name = null;
_vehicles.clear();
_refuels.clear();
_services.clear();
_loggedIn = false;
notifyListeners();
}
Map<String, String> _authHeaders() => {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
Future<bool> _validateToken() async {
if (_token == null) return false;
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/user/me'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_email = data['email'] ?? _email;
_name = data['username'] ?? data['name'] ?? _name;
if (_email != null) await _prefs.setString('email', _email!);
if (_name != null) await _prefs.setString('name', _name!);
return true;
}
} catch (_) {}
return false;
}
Future<void> fetchVehicles() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/vehicles'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_vehicles = data.map((e) => Vehicle.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {
// ignore for now
}
}
Future<void> addVehicle(Vehicle vehicle) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/vehicles'),
headers: _authHeaders(),
body: jsonEncode(vehicle.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_vehicles.add(Vehicle.fromApi(data));
notifyListeners();
}
} catch (_) {}
}
Future<void> updateVehicle(int index, Vehicle vehicle) async {
final id = _vehicles[index].id;
if (id == null) return;
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/vehicles/$id'),
headers: _authHeaders(),
body: jsonEncode(vehicle.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_vehicles[index] = Vehicle.fromApi(data);
notifyListeners();
}
} catch (_) {}
}
Future<void> removeVehicle(int index) async {
final id = _vehicles[index].id;
if (id == null) return;
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/vehicles/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_vehicles.removeAt(index);
notifyListeners();
}
} catch (_) {}
}
Future<void> setDefaultVehicle(String? id) async {
if (id == null) {
// Unset default from any vehicle currently marked as default
for (var i = 0; i < _vehicles.length; i++) {
if (_vehicles[i].isDefault) {
await updateVehicle(i, _vehicles[i].copyWith(isDefault: false));
}
}
} else {
// Clear default flag on all other vehicles first
for (var i = 0; i < _vehicles.length; i++) {
final vehicle = _vehicles[i];
if (vehicle.isDefault && vehicle.id != id) {
await updateVehicle(i, vehicle.copyWith(isDefault: false));
}
}
// Finally mark the selected vehicle as default
final idx = _vehicles.indexWhere((v) => v.id == id);
if (idx != -1) {
await updateVehicle(idx, _vehicles[idx].copyWith(isDefault: true));
}
}
await fetchVehicles();
}
// Refuel records
Future<void> fetchRefuels() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/refuels'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_refuels = data.map((e) => Refuel.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {}
}
Future<void> addRefuel(Refuel refuel) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/refuels'),
headers: _authHeaders(),
body: jsonEncode(refuel.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_refuels.add(Refuel.fromApi(data));
notifyListeners();
}
} catch (_) {}
}
Future<void> updateRefuel(String id, Refuel refuel) async {
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/refuels/$id'),
headers: _authHeaders(),
body: jsonEncode(refuel.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final idx = _refuels.indexWhere((r) => r.id == id);
if (idx != -1) {
_refuels[idx] = Refuel.fromApi(data);
notifyListeners();
}
}
} catch (_) {}
}
Future<void> removeRefuel(String id) async {
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/refuels/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_refuels.removeWhere((r) => r.id == id);
notifyListeners();
}
} catch (_) {}
}
// Service records
Future<void> fetchServices() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/services'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_services = data.map((e) => ServiceRecord.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {}
}
Future<bool> addService(ServiceRecord service) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/services'),
headers: _authHeaders(),
body: jsonEncode(service.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_services.add(ServiceRecord.fromApi(data));
notifyListeners();
return true;
}
} catch (_) {}
return false;
}
Future<bool> updateService(String id, ServiceRecord service) async {
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/services/$id'),
headers: _authHeaders(),
body: jsonEncode(service.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final idx = _services.indexWhere((s) => s.id == id);
if (idx != -1) {
_services[idx] = ServiceRecord.fromApi(data);
notifyListeners();
}
return true;
}
} catch (_) {}
return false;
}
Future<void> removeService(String id) async {
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/services/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_services.removeWhere((s) => s.id == id);
notifyListeners();
}
} catch (_) {}
}
}

View File

@@ -0,0 +1,98 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../models/refuel.dart';
class ConsumptionChart extends StatelessWidget {
final List<Refuel> refuels;
const ConsumptionChart({super.key, required this.refuels});
@override
Widget build(BuildContext context) {
final spots = <FlSpot>[];
final labels = <String>[];
for (var i = 1; i < refuels.length; i++) {
final prev = refuels[i - 1];
final curr = refuels[i];
final distance = curr.mileage - prev.mileage;
if (distance <= 0) continue;
final consumption =
double.parse((curr.liters / distance * 100).toStringAsFixed(2));
spots.add(FlSpot(spots.length.toDouble(), consumption));
final date = curr.createdAt;
labels.add(date != null ? '${date.month}/${date.day}' : '');
}
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
barWidth: 3,
color: Theme.of(context).colorScheme.secondary,
dotData: const FlDotData(show: true),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black87,
tooltipMargin: 40,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
'${s.y.toStringAsFixed(2)} L/100km',
const TextStyle(color: Colors.white)))
.toList(),
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameSize: 28,
axisNameWidget: const Padding(
padding: EdgeInsets.only(right: 8),
child: Text('L/100km'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(value.toStringAsFixed(1),
style: const TextStyle(fontSize: 10)),
),
),
),
bottomTitles: AxisTitles(
axisNameSize: 24,
axisNameWidget: const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Date'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= labels.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(labels[index],
style: const TextStyle(fontSize: 10)),
);
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class DataSection extends StatelessWidget {
final String title;
const DataSection({required this.title});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.all(16),
child: Container(
height: 200,
padding: EdgeInsets.all(16),
child: Center(child: Text(title, style: TextStyle(fontSize: 18))),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../models/refuel.dart';
class GasPriceChart extends StatelessWidget {
final List<Refuel> refuels;
const GasPriceChart({super.key, required this.refuels});
@override
Widget build(BuildContext context) {
final spots = <FlSpot>[];
final labels = <String>[];
for (var i = 0; i < refuels.length; i++) {
final refuel = refuels[i];
spots.add(FlSpot(spots.length.toDouble(), refuel.pricePerLiter));
final date = refuel.createdAt;
labels.add(date != null ? '${date.month}/${date.day}' : '');
}
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
barWidth: 3,
color: Theme.of(context).colorScheme.primary,
dotData: const FlDotData(show: true),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black87,
tooltipMargin: 40,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
s.y.toStringAsFixed(2),
const TextStyle(color: Colors.white)))
.toList(),
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameSize: 28,
axisNameWidget: const Padding(
padding: EdgeInsets.only(right: 8),
child: Text('Price/L'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(value.toStringAsFixed(1),
style: const TextStyle(fontSize: 10)),
),
),
),
bottomTitles: AxisTitles(
axisNameSize: 24,
axisNameWidget: const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Date'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= labels.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(labels[index],
style: const TextStyle(fontSize: 10)),
);
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class StatCard extends StatelessWidget {
final String title;
final String value;
const StatCard({super.key, required this.title, required this.value});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(title, textAlign: TextAlign.center),
],
),
),
);
}
}

1
linux/.gitignore vendored Normal file
View File

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

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