Update readme's, branches and actions
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m8s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
@@ -0,0 +1,44 @@
|
||||
# Flutter / Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
|
||||
# IDE / editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Generated and platform artifacts
|
||||
android/key.properties
|
||||
android/app/debug
|
||||
android/app/profile
|
||||
android/app/release
|
||||
|
||||
# Logs and temp files
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# Symbolication / obfuscation
|
||||
*.symbols
|
||||
*.map.json
|
||||
|
||||
# Legacy migration note
|
||||
legacy_windows_xp_plan.md
|
||||
|
||||
@@ -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: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: android
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: ios
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: linux
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: macos
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: web
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: windows
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
|
||||
# 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'
|
||||
@@ -0,0 +1,24 @@
|
||||
# Deprecated: Legacy Bus Terminal
|
||||
|
||||
This Flutter application is retained as a legacy reference implementation while the transit client moves away from the Android-era setup toward a Windows XP-era compatibility target.
|
||||
|
||||
## Status
|
||||
|
||||
- Deprecated: yes
|
||||
- Legacy platform target: Android-era Flutter app
|
||||
- Planned migration target: Windows XP-era desktop workflow
|
||||
- Kept in repo: for archival and reference during transition
|
||||
|
||||
## Notes
|
||||
|
||||
This project is not the active client for current operations. It remains available so the older UI, navigation logic, and service integration can be reviewed while the new target is prepared.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd deprecated
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
Use the appropriate device/desktop target when testing the legacy implementation.
|
||||
@@ -0,0 +1,5 @@
|
||||
analyzer:
|
||||
errors:
|
||||
deprecated_member_use: ignore
|
||||
use_build_context_synchronously: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.bus_terminal"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.bus_terminal"
|
||||
// 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 = "../.."
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,48 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<application
|
||||
android:label="bus_terminal"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.bus_terminal
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,620 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
|
||||
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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bus Terminal</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>bus_terminal</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/schedule_overview_screen.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'services/app_navigator.dart';
|
||||
import 'services/error_log_service.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
void report() => ErrorLogService.report(
|
||||
service: 'deprecated',
|
||||
partOfService: 'FlutterError.onError',
|
||||
error: details.exceptionAsString(),
|
||||
);
|
||||
// ignore: unawaited_futures
|
||||
report();
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
// ignore: unawaited_futures
|
||||
ErrorLogService.report(
|
||||
service: 'deprecated',
|
||||
partOfService: 'PlatformDispatcher.onError',
|
||||
error: '$error\n$stack',
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
runZonedGuarded(
|
||||
() {
|
||||
runApp(const MainApp());
|
||||
},
|
||||
(error, stack) {
|
||||
// ignore: unawaited_futures
|
||||
ErrorLogService.report(
|
||||
service: 'deprecated',
|
||||
partOfService: 'runZonedGuarded',
|
||||
error: '$error\n$stack',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Bus Terminal',
|
||||
navigatorKey: navigatorKey,
|
||||
scaffoldMessengerKey: messengerKey,
|
||||
theme: AppTheme.lightTheme(),
|
||||
darkTheme: AppTheme.darkTheme(),
|
||||
themeMode: ThemeMode.system,
|
||||
routes: {
|
||||
'/login': (context) => const LoginScreen(),
|
||||
'/schedule': (context) => const ScheduleOverviewScreen(),
|
||||
},
|
||||
home: const _InitialRoute(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial route checks if user is logged in
|
||||
class _InitialRoute extends StatelessWidget {
|
||||
const _InitialRoute();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<bool>(
|
||||
future: AuthService().isLoggedIn(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// If logged in, go to schedule; otherwise go to login
|
||||
return snapshot.data == true
|
||||
? const ScheduleOverviewScreen()
|
||||
: const LoginScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Navigation route point
|
||||
class NavigationPoint {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String? name;
|
||||
|
||||
NavigationPoint({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.name,
|
||||
});
|
||||
|
||||
LatLng toLatLng() => LatLng(latitude, longitude);
|
||||
|
||||
factory NavigationPoint.fromLatLng(LatLng latlng, {String? name}) {
|
||||
return NavigationPoint(
|
||||
latitude: latlng.latitude,
|
||||
longitude: latlng.longitude,
|
||||
name: name,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
factory NavigationPoint.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationPoint(
|
||||
latitude: json['latitude'] as double,
|
||||
longitude: json['longitude'] as double,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route leg with distance and duration
|
||||
class RouteLeg {
|
||||
final List<NavigationPoint> points;
|
||||
final double distance; // in meters
|
||||
final int duration; // in seconds
|
||||
final String summary;
|
||||
|
||||
RouteLeg({
|
||||
required this.points,
|
||||
required this.distance,
|
||||
required this.duration,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
|
||||
String getDurationMinutes() => (duration ~/ 60).toString();
|
||||
|
||||
factory RouteLeg.fromCoordinates(
|
||||
List<dynamic> coordinates, {
|
||||
double distance = 0.0,
|
||||
int duration = 0,
|
||||
String summary = 'Route',
|
||||
}) {
|
||||
return RouteLeg(
|
||||
points: coordinates
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList(),
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
factory RouteLeg.fromPoints({
|
||||
required List<NavigationPoint> points,
|
||||
required double distance,
|
||||
required int duration,
|
||||
String summary = 'Route',
|
||||
}) {
|
||||
return RouteLeg(
|
||||
points: points,
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
factory RouteLeg.fromJson(Map<String, dynamic> json) {
|
||||
final geometry = json['geometry'] as Map<String, dynamic>?;
|
||||
final coordinates = geometry?['coordinates'] as List? ?? [];
|
||||
|
||||
return RouteLeg(
|
||||
points: coordinates
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList(),
|
||||
distance: (json['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
||||
summary: (json['summary'] as String?) ?? 'Route',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete navigation route from start to end
|
||||
class NavigationRoute {
|
||||
final NavigationPoint start;
|
||||
final NavigationPoint end;
|
||||
final List<RouteLeg> legs;
|
||||
final double totalDistance; // in meters
|
||||
final int totalDuration; // in seconds
|
||||
|
||||
NavigationRoute({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.legs,
|
||||
required this.totalDistance,
|
||||
required this.totalDuration,
|
||||
});
|
||||
|
||||
String getTotalDistanceKm() => (totalDistance / 1000).toStringAsFixed(1);
|
||||
String getTotalDurationMinutes() => (totalDuration ~/ 60).toString();
|
||||
String getTotalDurationHM() {
|
||||
final hours = totalDuration ~/ 3600;
|
||||
final minutes = (totalDuration % 3600) ~/ 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
|
||||
List<NavigationPoint> getAllPoints() {
|
||||
final allPoints = <NavigationPoint>[];
|
||||
for (final leg in legs) {
|
||||
allPoints.addAll(leg.points);
|
||||
}
|
||||
return allPoints;
|
||||
}
|
||||
|
||||
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
|
||||
final routes = json['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes[0] as Map<String, dynamic>?;
|
||||
if (routeData == null) {
|
||||
throw Exception('Invalid route data');
|
||||
}
|
||||
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: NavigationPoint(latitude: 0, longitude: 0),
|
||||
end: NavigationPoint(latitude: 0, longitude: 0),
|
||||
);
|
||||
}
|
||||
|
||||
factory NavigationRoute.fromOsrmRouteData(
|
||||
Map<String, dynamic> routeData, {
|
||||
required NavigationPoint start,
|
||||
required NavigationPoint end,
|
||||
}) {
|
||||
final geometry = routeData['geometry'] as Map<String, dynamic>?;
|
||||
final coordinates = geometry?['coordinates'] as List? ?? [];
|
||||
final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (routeData['duration'] as num?)?.toInt() ?? 0;
|
||||
|
||||
final leg = RouteLeg.fromCoordinates(
|
||||
coordinates,
|
||||
distance: distance,
|
||||
duration: duration,
|
||||
summary: (routeData['summary'] as String?) ?? 'Route',
|
||||
);
|
||||
|
||||
return NavigationRoute(
|
||||
start: start,
|
||||
end: end,
|
||||
legs: [leg],
|
||||
totalDistance: distance,
|
||||
totalDuration: duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an optimized trip calculation with reordered stops.
|
||||
class OptimizedRouteResult {
|
||||
final NavigationRoute route;
|
||||
final List<NavigationPoint> orderedStops;
|
||||
|
||||
OptimizedRouteResult({
|
||||
required this.route,
|
||||
required this.orderedStops,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Bus Line model
|
||||
class BusLine {
|
||||
final int id;
|
||||
final int lineNumber;
|
||||
final String startStop;
|
||||
final String endStop;
|
||||
final int estimatedDuration; // in minutes
|
||||
final String? description;
|
||||
|
||||
BusLine({
|
||||
required this.id,
|
||||
required this.lineNumber,
|
||||
required this.startStop,
|
||||
required this.endStop,
|
||||
required this.estimatedDuration,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory BusLine.fromJson(Map<String, dynamic> json) {
|
||||
return BusLine(
|
||||
id: json['id'] as int,
|
||||
lineNumber: json['lineNumber'] as int,
|
||||
startStop: json['startStop'] as String,
|
||||
endStop: json['endStop'] as String,
|
||||
estimatedDuration: json['estimatedDuration'] as int,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'lineNumber': lineNumber,
|
||||
'startStop': startStop,
|
||||
'endStop': endStop,
|
||||
'estimatedDuration': estimatedDuration,
|
||||
'description': description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Bus type model
|
||||
class BusType {
|
||||
final int id;
|
||||
final String name;
|
||||
final int seatCapacity;
|
||||
final String licensePlate;
|
||||
|
||||
BusType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.seatCapacity,
|
||||
required this.licensePlate,
|
||||
});
|
||||
|
||||
factory BusType.fromJson(Map<String, dynamic> json) {
|
||||
return BusType(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
seatCapacity: json['seatCapacity'] as int,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'seatCapacity': seatCapacity,
|
||||
'licensePlate': licensePlate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Route model for daily schedule
|
||||
class Route {
|
||||
final int id;
|
||||
final BusLine busLine;
|
||||
final BusType busType;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final String status; // scheduled, in_progress, completed, cancelled
|
||||
|
||||
Route({
|
||||
required this.id,
|
||||
required this.busLine,
|
||||
required this.busType,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory Route.fromJson(Map<String, dynamic> json) {
|
||||
return Route(
|
||||
id: json['id'] as int,
|
||||
busLine: BusLine.fromJson(json['busLine'] as Map<String, dynamic>),
|
||||
busType: BusType.fromJson(json['busType'] as Map<String, dynamic>),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime:
|
||||
json['endTime'] != null ? DateTime.parse(json['endTime'] as String) : null,
|
||||
status: json['status'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'busLine': busLine.toJson(),
|
||||
'busType': busType.toJson(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'status': status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Daily schedule model
|
||||
class DailySchedule {
|
||||
final List<Route> routes;
|
||||
final DateTime date;
|
||||
|
||||
DailySchedule({
|
||||
required this.routes,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
factory DailySchedule.fromJson(Map<String, dynamic> json) {
|
||||
return DailySchedule(
|
||||
routes: (json['routes'] as List)
|
||||
.map((r) => Route.fromJson(r as Map<String, dynamic>))
|
||||
.toList(),
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'routes': routes.map((r) => r.toJson()).toList(),
|
||||
'date': date.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop model for the new schedule system.
|
||||
class ScheduleStop {
|
||||
final int id;
|
||||
final int order;
|
||||
final String name;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final int estimatedArrivalMinutes;
|
||||
|
||||
ScheduleStop({
|
||||
required this.id,
|
||||
required this.order,
|
||||
required this.name,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.estimatedArrivalMinutes,
|
||||
});
|
||||
|
||||
factory ScheduleStop.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduleStop(
|
||||
id: (json['id'] as num).toInt(),
|
||||
order: (json['order'] as num).toInt(),
|
||||
name: (json['name'] ?? json['stopName'] ?? '') as String,
|
||||
latitude: (json['latitude'] as num).toDouble(),
|
||||
longitude: (json['longitude'] as num).toDouble(),
|
||||
estimatedArrivalMinutes: (json['estimatedArrivalMinutes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule model for the new public schedule API.
|
||||
class ServiceSchedule {
|
||||
final int id;
|
||||
final int busLineId;
|
||||
final int busId;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final String status;
|
||||
final int lineNumber;
|
||||
final String busName;
|
||||
final List<int> weekdays;
|
||||
|
||||
ServiceSchedule({
|
||||
required this.id,
|
||||
required this.busLineId,
|
||||
required this.busId,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.status,
|
||||
required this.lineNumber,
|
||||
required this.busName,
|
||||
required this.weekdays,
|
||||
});
|
||||
|
||||
factory ServiceSchedule.fromJson(Map<String, dynamic> json) {
|
||||
final rawWeekdays = json['weekdays'];
|
||||
final weekdays = rawWeekdays is List
|
||||
? rawWeekdays.map((day) => (day as num).toInt()).toList()
|
||||
: rawWeekdays is String
|
||||
? (jsonDecode(rawWeekdays) as List).map((day) => (day as num).toInt()).toList()
|
||||
: <int>[];
|
||||
|
||||
return ServiceSchedule(
|
||||
id: (json['id'] as num).toInt(),
|
||||
busLineId: (json['busLineId'] as num).toInt(),
|
||||
busId: (json['busId'] as num).toInt(),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: DateTime.parse(json['endTime'] as String),
|
||||
status: (json['status'] ?? 'planned') as String,
|
||||
lineNumber: (json['lineNumber'] as num).toInt(),
|
||||
busName: (json['busName'] ?? '') as String,
|
||||
weekdays: weekdays,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isRecurring => weekdays.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/// User model
|
||||
class User {
|
||||
final int id;
|
||||
final int userCode;
|
||||
final String? job;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.userCode,
|
||||
this.job,
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
// userCode may be provided as 'userCode' or 'usercode' and may be int or string
|
||||
final dynamic rawUserCode = json['userCode'] ?? json['usercode'] ?? json['user_code'];
|
||||
int parsedUserCode;
|
||||
if (rawUserCode is int) {
|
||||
parsedUserCode = rawUserCode;
|
||||
} else if (rawUserCode is String) {
|
||||
parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode'));
|
||||
} else {
|
||||
// If the backend doesn't return a userCode, treat as unknown (-1)
|
||||
parsedUserCode = -1;
|
||||
}
|
||||
|
||||
return User(
|
||||
id: json['id'] as int,
|
||||
userCode: parsedUserCode,
|
||||
job: json['job'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userCode': userCode,
|
||||
'job': job,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Login response model
|
||||
class AuthResponse {
|
||||
final String token;
|
||||
final User? user;
|
||||
|
||||
AuthResponse({
|
||||
required this.token,
|
||||
this.user,
|
||||
});
|
||||
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||
final dynamic rawToken = json['token'] ?? json['Token'] ?? json['authToken'];
|
||||
final token = rawToken?.toString();
|
||||
if (token == null || token.isEmpty) throw FormatException('Missing token in auth response');
|
||||
|
||||
// Determine if a user object exists and appears valid
|
||||
Map<String, dynamic>? userMap;
|
||||
if (json.containsKey('user') && json['user'] is Map<String, dynamic>) {
|
||||
userMap = json['user'] as Map<String, dynamic>;
|
||||
} else {
|
||||
// maybe user fields are top-level (id, userCode, job)
|
||||
final hasUserFields = json.containsKey('id') || json.containsKey('userCode') || json.containsKey('usercode') || json.containsKey('user_code');
|
||||
if (hasUserFields) {
|
||||
// treat whole json as user map
|
||||
userMap = json.cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
|
||||
User? parsedUser;
|
||||
if (userMap != null) {
|
||||
try {
|
||||
parsedUser = User.fromJson(userMap);
|
||||
} catch (e) {
|
||||
// If parsing user fails, ignore user but keep token
|
||||
parsedUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
return AuthResponse(
|
||||
token: token,
|
||||
user: parsedUser,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/schedule.dart' as schedule;
|
||||
import '../services/schedule_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/exceptions.dart';
|
||||
import '../widgets/route_card.dart';
|
||||
|
||||
class DailyScheduleScreen extends StatefulWidget {
|
||||
const DailyScheduleScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DailyScheduleScreen> createState() => _DailyScheduleScreenState();
|
||||
}
|
||||
|
||||
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
||||
final _scheduleService = ScheduleService();
|
||||
final _authService = AuthService();
|
||||
late Future<schedule.DailySchedule> _scheduleFuture;
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleFuture = _scheduleService.getTodaySchedule();
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
await _authService.logout();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSchedule() async {
|
||||
setState(() {
|
||||
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 7)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dialogBackgroundColor: colorScheme.surface,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
});
|
||||
_refreshSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
// Group routes by hour for better visual organization
|
||||
Map<int, List<schedule.Route>> _groupByHour(List<schedule.Route> routes) {
|
||||
final grouped = <int, List<schedule.Route>>{};
|
||||
for (final route in routes) {
|
||||
final hour = route.startTime.hour;
|
||||
grouped.putIfAbsent(hour, () => []).add(route);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
void _moveDay(int deltaDays) {
|
||||
setState(() {
|
||||
_selectedDate = _selectedDate.add(Duration(days: deltaDays));
|
||||
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final dateStr =
|
||||
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
|
||||
final today = DateTime.now();
|
||||
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
|
||||
DateTime(today.year, today.month, today.day);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Today on the Road'),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Logout',
|
||||
onPressed: _handleLogout,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? const [Color(0xFF0B1220), Color(0xFF111827)]
|
||||
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isToday ? 'Vandaag' : 'Geplande datum',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.78),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Routes, stops en status in één overzicht',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.84),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _moveDay(-1),
|
||||
icon: const Icon(Icons.chevron_left, size: 18),
|
||||
label: const Text('Vorige dag'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _selectDate,
|
||||
icon: const Icon(Icons.calendar_today, size: 18),
|
||||
label: const Text('Kies datum'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _moveDay(1),
|
||||
icon: const Icon(Icons.chevron_right, size: 18),
|
||||
label: const Text('Volgende dag'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<schedule.DailySchedule>(
|
||||
future: _scheduleFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
// Auto-logout on session expiry
|
||||
if (snapshot.error is UnauthorizedException) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLogout();
|
||||
});
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
snapshot.error is UnauthorizedException
|
||||
? 'Session Expired'
|
||||
: 'Failed to load schedule',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
snapshot.error is UnauthorizedException
|
||||
? 'Redirecting to login...'
|
||||
: snapshot.error.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (snapshot.error is! UnauthorizedException) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _refreshSchedule,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Geen actieve ritten',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Je hebt geen geplande ritten voor deze dag',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final routes = [...snapshot.data!.routes]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
|
||||
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
|
||||
|
||||
// Merge and group by hour
|
||||
final allActiveRoutes = [...inProgressRoutes, ...scheduledRoutes]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
final hourGroups = _groupByHour(allActiveRoutes);
|
||||
final sortedHours = hourGroups.keys.toList()..sort();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshSchedule,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
if (allActiveRoutes.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
'Geen actieve ritten voor deze dag',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...sortedHours.map((hour) {
|
||||
final routesForHour = hourGroups[hour] ?? [];
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${hour.toString().padLeft(2, '0')}:00 uur',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...routesForHour.map((route) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: RouteCard(route: route, onTap: () {}),
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/error_log_service.dart';
|
||||
import '../widgets/custom_input_field.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _userCodeController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _authService = AuthService();
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_userCodeController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final input = _userCodeController.text.trim();
|
||||
final parsedUserCode = int.tryParse(input);
|
||||
if (parsedUserCode == null) {
|
||||
await ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: 'LoginScreen._handleLogin',
|
||||
error: 'User code must be a number',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'User code must be a number';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _authService.login(
|
||||
userCode: parsedUserCode,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
// Navigate to daily schedule screen
|
||||
Navigator.of(context).pushReplacementNamed('/schedule');
|
||||
}
|
||||
} catch (e) {
|
||||
await ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: 'LoginScreen._handleLogin.catch',
|
||||
error: e.toString(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateUserCode(String? value) {
|
||||
final v = value?.trim();
|
||||
if (v?.isEmpty ?? true) {
|
||||
return 'User code is required';
|
||||
}
|
||||
if (int.tryParse(v!) == null) {
|
||||
return 'User code must be a number';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if ((value?.length ?? 0) < 3) {
|
||||
return 'Password must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
|
||||
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.18),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.16),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.directions_bus,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Bus Terminal',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Driver access dashboard',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.82),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outline.withOpacity(0.14)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Driver Login',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Sign in to view your route, stops and live navigation.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.error.withOpacity(0.12),
|
||||
border: Border.all(color: colorScheme.error.withOpacity(0.35)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
CustomInputField(
|
||||
label: 'User Code',
|
||||
hint: 'Enter your user code',
|
||||
controller: _userCodeController,
|
||||
validator: _validateUserCode,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
CustomInputField(
|
||||
label: 'Password',
|
||||
hint: 'Enter your password',
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||
),
|
||||
)
|
||||
: const Text('Login'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_statusPill(Icons.shield_outlined, 'Secure access'),
|
||||
const SizedBox(width: 10),
|
||||
_statusPill(Icons.route_outlined, 'Live route data'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusPill(IconData icon, String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/navigation.dart';
|
||||
import '../models/schedule.dart' as schedule;
|
||||
import '../services/navigation_service.dart';
|
||||
import '../services/schedule_service.dart';
|
||||
|
||||
class NavigationScreen extends StatefulWidget {
|
||||
final schedule.Route route;
|
||||
final List<NavigationPoint>? initialStops;
|
||||
|
||||
const NavigationScreen({
|
||||
super.key,
|
||||
required this.route,
|
||||
this.initialStops,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NavigationScreen> createState() => _NavigationScreenState();
|
||||
}
|
||||
|
||||
class _NavigationScreenState extends State<NavigationScreen> {
|
||||
final _navigationService = NavigationService();
|
||||
final _scheduleService = ScheduleService();
|
||||
late MapController _mapController;
|
||||
|
||||
NavigationRoute? _currentRoute;
|
||||
NavigationPoint? _currentLocation;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
List<NavigationRoute>? _alternativeRoutes;
|
||||
List<NavigationPoint>? _stops;
|
||||
String? _stopsFetchError;
|
||||
int _currentStopIndex = 0;
|
||||
final Set<int> _completedStops = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = MapController();
|
||||
_initializeNavigation();
|
||||
}
|
||||
|
||||
Future<void> _initializeNavigation() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Get current location
|
||||
final currentLoc = await _navigationService.getCurrentLocation();
|
||||
setState(() => _currentLocation = currentLoc);
|
||||
|
||||
// Use provided initial stops when available, otherwise fetch from API
|
||||
if (widget.initialStops != null) {
|
||||
_stops = widget.initialStops;
|
||||
_stopsFetchError = null;
|
||||
} else {
|
||||
try {
|
||||
_stops = await _scheduleService.getStops(widget.route.busLine.id);
|
||||
_stopsFetchError = null;
|
||||
} catch (e) {
|
||||
_stops = [];
|
||||
_stopsFetchError = e.toString().replaceAll('Exception: ', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: filter out invalid stops (missing/NaN coordinates) and deduplicate
|
||||
if (_stops != null) {
|
||||
_stops = _stops!
|
||||
.where((s) => s.latitude.isFinite && s.longitude.isFinite)
|
||||
.toList();
|
||||
|
||||
// Remove exact duplicates by lat/lon
|
||||
final seen = <String>{};
|
||||
_stops = _stops!.where((s) {
|
||||
final key = '${s.latitude.toStringAsFixed(6)}|${s.longitude.toStringAsFixed(6)}';
|
||||
if (seen.contains(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Determine start and end points: prefer stop locations if available.
|
||||
// If no stops are returned from the API, geocode the stop names.
|
||||
late NavigationPoint startPoint;
|
||||
late NavigationPoint endPoint;
|
||||
|
||||
if (_stops != null && _stops!.length >= 2) {
|
||||
final hasScheduleStops = widget.initialStops != null;
|
||||
|
||||
if (!hasScheduleStops) {
|
||||
final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!);
|
||||
_stops = optimized.orderedStops;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentRoute = optimized.route;
|
||||
_alternativeRoutes = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(optimized.route));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final navRoute = await _navigationService.getRouteThroughStops(stops: _stops!);
|
||||
startPoint = _stops!.first;
|
||||
endPoint = _stops!.last;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentRoute = navRoute;
|
||||
_alternativeRoutes = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (_stops != null && _stops!.length == 1) {
|
||||
// With exactly one linked stop, force that stop into the route as start
|
||||
// so the marker is guaranteed to be in-view.
|
||||
startPoint = _stops!.first;
|
||||
|
||||
try {
|
||||
endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop);
|
||||
} catch (_) {
|
||||
endPoint = NavigationPoint(
|
||||
latitude: startPoint.latitude + 0.01,
|
||||
longitude: startPoint.longitude + 0.01,
|
||||
name: widget.route.busLine.endStop,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Try geocoding names; fall back to conservative defaults on failure
|
||||
try {
|
||||
startPoint = await _navigationService.geocodePlace(widget.route.busLine.startStop);
|
||||
} catch (_) {
|
||||
startPoint = NavigationPoint(
|
||||
latitude: 52.5200,
|
||||
longitude: 13.4050,
|
||||
name: widget.route.busLine.startStop,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop);
|
||||
} catch (_) {
|
||||
endPoint = NavigationPoint(
|
||||
latitude: 52.5300,
|
||||
longitude: 13.4150,
|
||||
name: widget.route.busLine.endStop,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get route without waypoints
|
||||
final navRoute = await _navigationService.getRoute(start: startPoint, end: endPoint);
|
||||
|
||||
// Get alternative routes (but cap to a small number)
|
||||
final alternatives = await _navigationService.getMultipleRoutes(start: startPoint, end: endPoint);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentRoute = navRoute;
|
||||
_alternativeRoutes = alternatives.take(2).toList();
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Fit map to route bounds after first frame so MapController is attached
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _fitMapToRoute(NavigationRoute route) {
|
||||
// Build list of points to include in bounds, filter invalids
|
||||
final allPoints = <LatLng>[];
|
||||
allPoints.addAll(route.getAllPoints().where((p) => p.latitude.isFinite && p.longitude.isFinite).map((point) => LatLng(point.latitude, point.longitude)));
|
||||
allPoints.addAll((_stops ?? []).where((s) => s.latitude.isFinite && s.longitude.isFinite).map((stop) => LatLng(stop.latitude, stop.longitude)));
|
||||
if (_currentLocation != null && _currentLocation!.latitude.isFinite && _currentLocation!.longitude.isFinite) {
|
||||
allPoints.add(LatLng(_currentLocation!.latitude, _currentLocation!.longitude));
|
||||
}
|
||||
|
||||
if (allPoints.isEmpty) return;
|
||||
|
||||
double minLat = allPoints.first.latitude;
|
||||
double maxLat = allPoints.first.latitude;
|
||||
double minLon = allPoints.first.longitude;
|
||||
double maxLon = allPoints.first.longitude;
|
||||
|
||||
for (final point in allPoints) {
|
||||
minLat = min(minLat, point.latitude);
|
||||
maxLat = max(maxLat, point.latitude);
|
||||
minLon = min(minLon, point.longitude);
|
||||
maxLon = max(maxLon, point.longitude);
|
||||
}
|
||||
|
||||
// Clamp bounds to a reasonable maximum span to avoid huge map tiles requests
|
||||
const double maxSpanDegrees = 2.0; // ~222km lat span per degree ~111km; 2 degrees ~222km
|
||||
final latSpan = maxLat - minLat;
|
||||
final lonSpan = maxLon - minLon;
|
||||
|
||||
double centerLat = (minLat + maxLat) / 2.0;
|
||||
double centerLon = (minLon + maxLon) / 2.0;
|
||||
|
||||
double halfLat = latSpan / 2.0;
|
||||
double halfLon = lonSpan / 2.0;
|
||||
|
||||
if (latSpan > maxSpanDegrees) halfLat = maxSpanDegrees / 2.0;
|
||||
if (lonSpan > maxSpanDegrees) halfLon = maxSpanDegrees / 2.0;
|
||||
|
||||
final clampedMinLat = centerLat - halfLat;
|
||||
final clampedMaxLat = centerLat + halfLat;
|
||||
final clampedMinLon = centerLon - halfLon;
|
||||
final clampedMaxLon = centerLon + halfLon;
|
||||
|
||||
final bounds = LatLngBounds(
|
||||
LatLng(clampedMinLat, clampedMinLon),
|
||||
LatLng(clampedMaxLat, clampedMaxLon),
|
||||
);
|
||||
|
||||
_mapController.fitBounds(
|
||||
bounds,
|
||||
options: const FitBoundsOptions(padding: EdgeInsets.all(100)),
|
||||
);
|
||||
}
|
||||
|
||||
int _nextUncompletedStopIndex([int startIndex = 0]) {
|
||||
if (_stops == null || _stops!.isEmpty) return 0;
|
||||
|
||||
for (var index = startIndex; index < _stops!.length; index++) {
|
||||
if (!_completedStops.contains(index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return _stops!.length - 1;
|
||||
}
|
||||
|
||||
Future<void> _refreshRouteFromIndex(int index) async {
|
||||
if (_stops == null || _stops!.isEmpty) return;
|
||||
if (index < 0 || index >= _stops!.length) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_currentStopIndex = index;
|
||||
});
|
||||
|
||||
try {
|
||||
final remainingStops = _stops!.sublist(index);
|
||||
final navRoute = remainingStops.length >= 2
|
||||
? await _navigationService.getRouteThroughStops(stops: remainingStops)
|
||||
: _currentRoute;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (navRoute != null) {
|
||||
_currentRoute = navRoute;
|
||||
}
|
||||
_alternativeRoutes = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (navRoute != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _focusStop(int index) async {
|
||||
if (_stops == null || _stops!.isEmpty) return;
|
||||
if (index < 0 || index >= _stops!.length) return;
|
||||
await _refreshRouteFromIndex(index);
|
||||
}
|
||||
|
||||
Future<void> _markStopCompleted(int index) async {
|
||||
if (_stops == null || _stops!.isEmpty) return;
|
||||
if (index < 0 || index >= _stops!.length) return;
|
||||
|
||||
// When this screen is opened from schedule overview, route.id maps to scheduleId.
|
||||
if (widget.initialStops != null) {
|
||||
try {
|
||||
await _scheduleService.markScheduleStopPassed(
|
||||
scheduleId: widget.route.id,
|
||||
stopOrder: index + 1,
|
||||
actualPassedAt: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Stop update mislukt: ${e.toString().replaceAll('Exception: ', '')}';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_completedStops.add(index);
|
||||
});
|
||||
|
||||
final nextIndex = _nextUncompletedStopIndex(index + 1);
|
||||
await _refreshRouteFromIndex(nextIndex);
|
||||
}
|
||||
|
||||
double get _routeProgress {
|
||||
if (_stops == null || _stops!.isEmpty) return 0;
|
||||
if (_stops!.length == 1) return 1;
|
||||
return _currentStopIndex / (_stops!.length - 1);
|
||||
}
|
||||
|
||||
Color get _routeColor => Color.lerp(Colors.blue, Colors.orange, _routeProgress) ?? Colors.blue;
|
||||
|
||||
Color _stopColor(int index) {
|
||||
if (_completedStops.contains(index) || index < _currentStopIndex) return Colors.green;
|
||||
if (index == _currentStopIndex) return Colors.orange;
|
||||
return Colors.blueGrey;
|
||||
}
|
||||
|
||||
IconData _stopIcon(int index) {
|
||||
if (_completedStops.contains(index) || index < _currentStopIndex) return Icons.check_circle;
|
||||
if (index == _currentStopIndex) return Icons.radio_button_checked;
|
||||
return Icons.location_on;
|
||||
}
|
||||
|
||||
bool _isStopCompleted(int index) => _completedStops.contains(index) || index < _currentStopIndex;
|
||||
|
||||
bool _hasStopNearPoint(NavigationPoint point, {double threshold = 0.00005}) {
|
||||
if (_stops == null || _stops!.isEmpty) return false;
|
||||
|
||||
for (final stop in _stops!) {
|
||||
final latDiff = (stop.latitude - point.latitude).abs();
|
||||
final lonDiff = (stop.longitude - point.longitude).abs();
|
||||
if (latDiff <= threshold && lonDiff <= threshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
NavigationPoint? get _currentStopPoint {
|
||||
if (_stops == null || _stops!.isEmpty) return null;
|
||||
return _stops![_currentStopIndex.clamp(0, _stops!.length - 1)];
|
||||
}
|
||||
|
||||
NavigationPoint? get _nextStopPoint {
|
||||
if (_stops == null || _stops!.isEmpty) return null;
|
||||
final nextIndex = _nextUncompletedStopIndex(_currentStopIndex + 1);
|
||||
return _stops![nextIndex.clamp(0, _stops!.length - 1)];
|
||||
}
|
||||
|
||||
double get _completionRatio {
|
||||
if (_stops == null || _stops!.isEmpty) return 0;
|
||||
return _completedStops.length / _stops!.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Route ${widget.route.busLine.lineNumber} Navigation'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
// Map area (left pane)
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: _currentRoute != null
|
||||
? FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(
|
||||
_currentRoute!.start.latitude,
|
||||
_currentRoute!.start.longitude,
|
||||
),
|
||||
initialZoom: 13.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.example.bus_terminal',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
...(_stops ?? []).asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final stop = entry.value;
|
||||
final color = _stopColor(index);
|
||||
|
||||
return Marker(
|
||||
point: LatLng(stop.latitude, stop.longitude),
|
||||
width: 46,
|
||||
height: 46,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.22),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_stopIcon(index),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
// Start marker
|
||||
if (!_hasStopNearPoint(_currentRoute!.start))
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_currentRoute!.start.latitude,
|
||||
_currentRoute!.start.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
// End marker
|
||||
if (!_hasStopNearPoint(_currentRoute!.end))
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_currentRoute!.end.latitude,
|
||||
_currentRoute!.end.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Current location marker
|
||||
if (_currentLocation != null)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_currentLocation!.latitude,
|
||||
_currentLocation!.longitude,
|
||||
),
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.directions_bus,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
// Main route
|
||||
Polyline(
|
||||
points: _currentRoute!
|
||||
.getAllPoints()
|
||||
.map((p) => LatLng(p.latitude, p.longitude))
|
||||
.toList(),
|
||||
color: _routeColor,
|
||||
strokeWidth: 5,
|
||||
),
|
||||
// Alternative routes (lighter)
|
||||
if (_alternativeRoutes != null)
|
||||
...List.generate(
|
||||
_alternativeRoutes!.length,
|
||||
(i) => Polyline(
|
||||
points: _alternativeRoutes![i]
|
||||
.getAllPoints()
|
||||
.map((p) => LatLng(p.latitude, p.longitude))
|
||||
.toList(),
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: (_isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Center(
|
||||
child: _errorMessage != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load route',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text('No route'))),
|
||||
),
|
||||
|
||||
// Vertical divider
|
||||
Container(width: 1, color: Colors.grey[200]),
|
||||
|
||||
// Stops / Info panel (right pane)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.18),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.route,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Route ${widget.route.busLine.lineNumber}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${widget.route.busLine.startStop} → ${widget.route.busLine.endStop}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 10,
|
||||
value: _completionRatio,
|
||||
backgroundColor: Colors.white.withOpacity(0.12),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${(_completionRatio * 100).round()}% afgerond',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_completedStops.length}/${_stops?.length ?? 0} stops',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryPill(
|
||||
'Huidig',
|
||||
_currentStopPoint?.name ?? 'Geen stop',
|
||||
Icons.radio_button_checked,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildSummaryPill(
|
||||
'Volgend',
|
||||
_nextStopPoint?.name ?? 'Einde route',
|
||||
Icons.skip_next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF0F172A),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _stops == null || _stops!.isEmpty || _isStopCompleted(_currentStopIndex)
|
||||
? null
|
||||
: () => _markStopCompleted(_currentStopIndex),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Markeer huidige stop afgerond'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_legendDot(Colors.green, 'Afgerond'),
|
||||
const SizedBox(width: 12),
|
||||
_legendDot(Colors.orange, 'Actief'),
|
||||
const SizedBox(width: 12),
|
||||
_legendDot(Colors.blueGrey, 'Te doen'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_stopsFetchError != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.25)),
|
||||
),
|
||||
child: Text(
|
||||
'Stops konden niet geladen worden voor buslijn ${widget.route.busLine.id}: $_stopsFetchError',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Stops',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: _stops == null || _stops!.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No stops available for this line',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: _stops!.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final s = _stops![index];
|
||||
final color = _stopColor(index);
|
||||
final completed = _isStopCompleted(index);
|
||||
final isCurrent = index == _currentStopIndex;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: color,
|
||||
child: Icon(
|
||||
_stopIcon(index),
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
title: Text(s.name ?? 'Stop ${index + 1}'),
|
||||
subtitle: Text('${s.latitude.toStringAsFixed(5)}, ${s.longitude.toStringAsFixed(5)}'),
|
||||
trailing: completed
|
||||
? const Chip(
|
||||
label: Text('Afgerond'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: () => _markStopCompleted(index),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: Text(isCurrent ? 'Afronden' : 'Klaarzetten'),
|
||||
),
|
||||
onTap: () => _focusStop(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legendDot(Color color, String label) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryPill(String title, String value, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.16)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.75),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/schedule.dart' as sched;
|
||||
import '../services/schedule_service.dart';
|
||||
|
||||
class RecurringSchedulesScreen extends StatefulWidget {
|
||||
const RecurringSchedulesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RecurringSchedulesScreen> createState() => _RecurringSchedulesScreenState();
|
||||
}
|
||||
|
||||
class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
|
||||
final _scheduleService = ScheduleService();
|
||||
late Future<List<sched.ServiceSchedule>> _schedulesFuture;
|
||||
String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
|
||||
final Set<int> _expandedLineIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecurringSchedules();
|
||||
}
|
||||
|
||||
void _loadRecurringSchedules() {
|
||||
_schedulesFuture = _scheduleService.getRecurringSchedules();
|
||||
}
|
||||
|
||||
String _formatWeekdays(List<int> weekdays) {
|
||||
const dayLabels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
|
||||
if (weekdays.isEmpty) return 'Eenmalig';
|
||||
if (weekdays.length == 7) return 'Dagelijks';
|
||||
|
||||
final sorted = weekdays.toSet().toList()..sort((a, b) => a.compareTo(b));
|
||||
return sorted.map((d) => dayLabels[d]).join(', ');
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
List<sched.ServiceSchedule> _sortSchedules(List<sched.ServiceSchedule> schedules) {
|
||||
final sorted = List<sched.ServiceSchedule>.from(schedules);
|
||||
|
||||
switch (_selectedSortBy) {
|
||||
case 'line':
|
||||
sorted.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
|
||||
break;
|
||||
case 'time':
|
||||
sorted.sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
break;
|
||||
case 'weekday':
|
||||
sorted.sort((a, b) {
|
||||
final aFirst = a.weekdays.isEmpty ? 7 : a.weekdays.first;
|
||||
final bFirst = b.weekdays.isEmpty ? 7 : b.weekdays.first;
|
||||
return aFirst.compareTo(bFirst);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
Map<int, List<sched.ServiceSchedule>> _groupByLine(List<sched.ServiceSchedule> schedules) {
|
||||
final grouped = <int, List<sched.ServiceSchedule>>{};
|
||||
for (final schedule in schedules) {
|
||||
grouped.putIfAbsent(schedule.lineNumber, () => []).add(schedule);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Vaste Diensten'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
setState(() => _selectedSortBy = value);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'line',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.sort_by_alpha, color: _selectedSortBy == 'line' ? colorScheme.primary : null),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Lijn'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'time',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.schedule, color: _selectedSortBy == 'time' ? colorScheme.primary : null),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Tijd'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'weekday',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.calendar_today, color: _selectedSortBy == 'weekday' ? colorScheme.primary : null),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Weekdag'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Icon(Icons.filter_list),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<sched.ServiceSchedule>>(
|
||||
future: _schedulesFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Fout bij laden: ${snapshot.error}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() => _loadRecurringSchedules());
|
||||
},
|
||||
child: const Text('Opnieuw proberen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allSchedules = snapshot.data ?? [];
|
||||
// Filter to show only truly recurring schedules (with weekdays > 0)
|
||||
final schedules = allSchedules.where((s) => s.weekdays.isNotEmpty).toList();
|
||||
|
||||
if (schedules.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Geen herhaalde diensten gevonden',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Herhaalde diensten worden hier weergegeven',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sortedSchedules = _sortSchedules(schedules);
|
||||
final groupedByLine = _groupByLine(sortedSchedules);
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
itemCount: groupedByLine.length,
|
||||
itemBuilder: (context, lineIndex) {
|
||||
final lineNumber = groupedByLine.keys.toList()[lineIndex];
|
||||
final lineSchedules = groupedByLine[lineNumber] ?? [];
|
||||
final isExpanded = _expandedLineIds.contains(lineNumber);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isExpanded) {
|
||||
_expandedLineIds.remove(lineNumber);
|
||||
} else {
|
||||
_expandedLineIds.add(lineNumber);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lijn $lineNumber',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${lineSchedules.length} diensten',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: lineSchedules.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, scheduleIndex) {
|
||||
final schedule = lineSchedules[scheduleIndex];
|
||||
return _ScheduleCard(schedule: schedule, formatTime: _formatTime, formatWeekdays: _formatWeekdays);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScheduleCard extends StatelessWidget {
|
||||
final sched.ServiceSchedule schedule;
|
||||
final String Function(DateTime) formatTime;
|
||||
final String Function(List<int>) formatWeekdays;
|
||||
|
||||
const _ScheduleCard({
|
||||
required this.schedule,
|
||||
required this.formatTime,
|
||||
required this.formatWeekdays,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${formatTime(schedule.startTime)} - ${formatTime(schedule.endTime)}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
schedule.busName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
formatWeekdays(schedule.weekdays),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
avatar: const Icon(Icons.calendar_today, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/schedule.dart' as sched;
|
||||
import '../models/navigation.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/schedule_service.dart';
|
||||
import '../screens/navigation_screen.dart';
|
||||
import '../screens/recurring_schedules_screen.dart';
|
||||
|
||||
class ScheduleOverviewScreen extends StatefulWidget {
|
||||
const ScheduleOverviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
|
||||
}
|
||||
|
||||
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
|
||||
final _scheduleService = ScheduleService();
|
||||
final _authService = AuthService();
|
||||
|
||||
late Future<List<sched.ServiceSchedule>> _scheduleFuture;
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
await _authService.logout();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSchedules() async {
|
||||
setState(() {
|
||||
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 7)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dialogBackgroundColor: colorScheme.surface,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
});
|
||||
_refreshSchedules();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScheduleDetails(sched.ServiceSchedule svc) async {
|
||||
try {
|
||||
final stops = await _scheduleService.getScheduleStops(svc.id);
|
||||
if (!mounted) return;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.75,
|
||||
minChildSize: 0.45,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
Text(
|
||||
'Lijn ${svc.lineNumber}',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
svc.busName,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_formatTime(svc.startTime)} - ${_formatTime(svc.endTime)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoChip(label: svc.status),
|
||||
_InfoChip(label: svc.isRecurring ? _weekdayLabel(svc.weekdays) : 'Eenmalig'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await _scheduleService.updateScheduleStatus(
|
||||
svc.id,
|
||||
'in_progress',
|
||||
date: svc.startTime,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
||||
final routeObj = sched.Route(
|
||||
id: svc.id,
|
||||
busLine: sched.BusLine(
|
||||
id: svc.busLineId,
|
||||
lineNumber: svc.lineNumber,
|
||||
startStop: '',
|
||||
endStop: '',
|
||||
estimatedDuration: 0,
|
||||
),
|
||||
busType: sched.BusType(
|
||||
id: svc.busId,
|
||||
name: svc.busName,
|
||||
seatCapacity: 0,
|
||||
licensePlate: '',
|
||||
),
|
||||
startTime: svc.startTime,
|
||||
endTime: svc.endTime,
|
||||
status: 'in_progress',
|
||||
);
|
||||
|
||||
final navStops = stops
|
||||
.map((s) => NavigationPoint(
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
name: s.name,
|
||||
))
|
||||
.toList();
|
||||
|
||||
try {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
|
||||
),
|
||||
);
|
||||
} catch (navErr) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Status bijwerken mislukt: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.navigation),
|
||||
label: const Text('Start route'),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Haltes',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (stops.isEmpty)
|
||||
Text(
|
||||
'Geen haltes gekoppeld aan deze dienst.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
)
|
||||
else
|
||||
...stops.map((stop) {
|
||||
final departureTime = svc.startTime.add(
|
||||
Duration(minutes: stop.estimatedArrivalMinutes),
|
||||
);
|
||||
return Card(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: Text('${stop.order}. ${stop.name}'),
|
||||
subtitle: Text('${stop.estimatedArrivalMinutes} min na vertrek'),
|
||||
trailing: Text(_formatTime(departureTime)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Kon dienstdetails niet laden: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Diensten'),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
|
||||
Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
|
||||
Tab(icon: Icon(Icons.timeline), text: 'Voortgang'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Logout',
|
||||
onPressed: _handleLogout,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_DailyScheduleTab(
|
||||
scheduleService: _scheduleService,
|
||||
selectedDate: _selectedDate,
|
||||
onSelectDate: _selectDate,
|
||||
onRefresh: _refreshSchedules,
|
||||
onShowDetails: _showScheduleDetails,
|
||||
scheduleFuture: _scheduleFuture,
|
||||
),
|
||||
const RecurringSchedulesScreen(),
|
||||
_ProgressTab(
|
||||
scheduleService: _scheduleService,
|
||||
selectedDate: _selectedDate,
|
||||
scheduleFuture: _scheduleFuture,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressTab extends StatelessWidget {
|
||||
final ScheduleService scheduleService;
|
||||
final DateTime selectedDate;
|
||||
final Future<List<sched.ServiceSchedule>> scheduleFuture;
|
||||
|
||||
const _ProgressTab({
|
||||
required this.scheduleService,
|
||||
required this.selectedDate,
|
||||
required this.scheduleFuture,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateStr = '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Voortgang voor $dateStr', style: Theme.of(context).textTheme.titleLarge),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
// refresh handled by parent via reselecting date; show a simple feedback
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ververs de datum in Dagschema-tab om andere datum te laden')));
|
||||
},
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<sched.ServiceSchedule>>(
|
||||
future: scheduleFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||
if (snapshot.hasError) return Center(child: Text('Fout bij laden: ${snapshot.error}'));
|
||||
|
||||
final schedules = snapshot.data ?? [];
|
||||
if (schedules.isEmpty) return Center(child: Text('Geen diensten op $dateStr'));
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: schedules.length,
|
||||
itemBuilder: (context, index) {
|
||||
final svc = schedules[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
|
||||
subtitle: Text(_formatTime(svc.startTime)),
|
||||
trailing: TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final progress = await scheduleService.getScheduleProgress(svc.id, selectedDate);
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) {
|
||||
final stops = (progress['stops'] as List<dynamic>?) ?? [];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Voortgang — Lijn ${svc.lineNumber}', style: Theme.of(ctx).textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text('Status: ${progress['schedule']?['status'] ?? 'onbekend'}'),
|
||||
Text('Driver state: ${progress['driverState'] ?? 'onbekend'}'),
|
||||
Text('Passed: ${progress['passedStopCount'] ?? 0} / ${progress['totalStopCount'] ?? 0}'),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Haltes:'),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: stops.length,
|
||||
itemBuilder: (c, i) {
|
||||
final s = stops[i] as Map<String, dynamic>;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text('${s['stopOrder']}. ${s['stopName']}'),
|
||||
subtitle: Text('Scheduled: ${s['scheduledPassedAt'] ?? '-'}\nActual: ${s['actualPassedAt'] ?? '-'}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e')));
|
||||
}
|
||||
},
|
||||
child: const Text('Bekijk voortgang'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DailyScheduleTab extends StatelessWidget {
|
||||
final ScheduleService scheduleService;
|
||||
final DateTime selectedDate;
|
||||
final VoidCallback onSelectDate;
|
||||
final VoidCallback onRefresh;
|
||||
final Function(sched.ServiceSchedule) onShowDetails;
|
||||
final Future<List<sched.ServiceSchedule>> scheduleFuture;
|
||||
|
||||
const _DailyScheduleTab({
|
||||
required this.scheduleService,
|
||||
required this.selectedDate,
|
||||
required this.onSelectDate,
|
||||
required this.onRefresh,
|
||||
required this.onShowDetails,
|
||||
required this.scheduleFuture,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final dateStr =
|
||||
'${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: Theme.of(context).brightness == Brightness.dark
|
||||
? const [Color(0xFF0B1220), Color(0xFF111827)]
|
||||
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Geplande diensten',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.78),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.84),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onSelectDate,
|
||||
icon: const Icon(Icons.calendar_today, size: 18),
|
||||
label: const Text('Wijzig datum'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<sched.ServiceSchedule>>(
|
||||
future: scheduleFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: onRefresh,
|
||||
child: const Text('Opnieuw proberen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final schedules = snapshot.data ?? [];
|
||||
if (schedules.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => onRefresh(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
|
||||
itemCount: schedules.length,
|
||||
itemBuilder: (context, index) {
|
||||
final schedule = schedules[index];
|
||||
return _ScheduleSummaryCard(
|
||||
schedule: schedule,
|
||||
onTap: () => onShowDetails(schedule),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScheduleSummaryCard extends StatelessWidget {
|
||||
final sched.ServiceSchedule schedule;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ScheduleSummaryCard({
|
||||
required this.schedule,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
elevation: 0,
|
||||
color: colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.primary, colorScheme.tertiary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Line ${schedule.lineNumber}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Only show status for one-time schedules; recurring schedules have no fixed status
|
||||
if (!schedule.isRecurring)
|
||||
_InfoChip(label: schedule.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
schedule.busName,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalige dienst',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: onTap,
|
||||
icon: const Icon(Icons.list_alt, size: 18),
|
||||
label: const Text('Haltes'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _InfoChip({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _weekdayLabel(List<int> weekdays) {
|
||||
const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
|
||||
if (weekdays.isEmpty) {
|
||||
return 'Eenmalige dienst';
|
||||
}
|
||||
if (weekdays.length == 7) {
|
||||
return 'Dagelijks';
|
||||
}
|
||||
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'auth_service.dart';
|
||||
|
||||
/// Global navigator and messenger keys so services can navigate/logout
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
final GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Handle expired authentication: clear stored token and navigate to login.
|
||||
Future<void> handleAuthExpired({String? message}) async {
|
||||
try {
|
||||
await AuthService().logout();
|
||||
} catch (_) {}
|
||||
|
||||
// Show a message if possible
|
||||
try {
|
||||
final messenger = messengerKey.currentState;
|
||||
if (messenger != null && message != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Navigate to login, removing all previous routes
|
||||
try {
|
||||
final nav = navigatorKey.currentState;
|
||||
if (nav != null) {
|
||||
nav.pushNamedAndRemoveUntil('/login', (r) => false);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/user.dart';
|
||||
import 'error_log_service.dart';
|
||||
|
||||
/// Authentication Service
|
||||
class AuthService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
static const String _tokenKey = 'auth_token';
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initPrefs() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// Login with userCode and password
|
||||
Future<AuthResponse> login({
|
||||
required int userCode,
|
||||
required String password,
|
||||
}) async {
|
||||
await _initPrefs();
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$_apiBaseUrl/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'userCode': userCode,
|
||||
'password': password,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = response.body;
|
||||
try {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
final authResponse = AuthResponse.fromJson(data);
|
||||
|
||||
// Store token
|
||||
await _prefs.setString(_tokenKey, authResponse.token);
|
||||
|
||||
return authResponse;
|
||||
} catch (e) {
|
||||
await _reportError('AuthService.login.parse', e);
|
||||
// Surface the response body to help debug backend field names / formats
|
||||
throw Exception('Login parse error: $e -- response body: $body');
|
||||
}
|
||||
} else if (response.statusCode == 401) {
|
||||
throw Exception('Invalid credentials');
|
||||
} else {
|
||||
throw Exception('Login failed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('AuthService.login', e);
|
||||
throw Exception('Login error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stored token
|
||||
Future<String?> getToken() async {
|
||||
await _initPrefs();
|
||||
return _prefs.getString(_tokenKey);
|
||||
}
|
||||
|
||||
/// Check if user is logged in
|
||||
Future<bool> isLoggedIn() async {
|
||||
final token = await getToken();
|
||||
return token != null;
|
||||
}
|
||||
|
||||
/// Logout
|
||||
Future<void> logout() async {
|
||||
await _initPrefs();
|
||||
await _prefs.remove(_tokenKey);
|
||||
}
|
||||
|
||||
/// Get Authorization header
|
||||
Future<Map<String, String>> getAuthHeaders() async {
|
||||
final token = await getToken();
|
||||
return {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class ErrorLogService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
|
||||
static Future<void> report({
|
||||
required String service,
|
||||
required String partOfService,
|
||||
required String error,
|
||||
}) async {
|
||||
try {
|
||||
await http.post(
|
||||
Uri.parse('$_apiBaseUrl/error_log'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'service': service,
|
||||
'partOfService': partOfService,
|
||||
'error': error,
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
// Intentionally ignore logging failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Custom exception for authentication errors
|
||||
import 'app_navigator.dart';
|
||||
|
||||
class UnauthorizedException implements Exception {
|
||||
final String message;
|
||||
|
||||
UnauthorizedException(this.message) {
|
||||
// Trigger centralized logout/navigation asynchronously
|
||||
try {
|
||||
handleAuthExpired(message: message);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import 'dart:convert';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'error_log_service.dart';
|
||||
import '../models/navigation.dart';
|
||||
|
||||
/// Navigation Service using OpenStreetMap
|
||||
class NavigationService {
|
||||
// OSRM (OpenStreetMap Routing Machine) - free public service
|
||||
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving';
|
||||
|
||||
/// Get current device location
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
Future<NavigationPoint?> getCurrentLocation() async {
|
||||
try {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
|
||||
return NavigationPoint(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
name: 'Current Location',
|
||||
);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getCurrentLocation', e);
|
||||
throw Exception('Failed to get location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get route between two points
|
||||
/// Uses OSRM for open routing without API key
|
||||
Future<NavigationRoute> getRoute({
|
||||
required NavigationPoint start,
|
||||
required NavigationPoint end,
|
||||
}) async {
|
||||
try {
|
||||
bool validCoord(double lat, double lon) {
|
||||
return lat.isFinite && lon.isFinite && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
if (!validCoord(start.latitude, start.longitude) || !validCoord(end.latitude, end.longitude)) {
|
||||
throw Exception('Invalid coordinates for routing: start=(${start.latitude},${start.longitude}) end=(${end.latitude},${end.longitude})');
|
||||
}
|
||||
// OSRM format: /route/v1/driving/lon1,lat1;lon2,lat2
|
||||
final coordinates =
|
||||
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}';
|
||||
final url =
|
||||
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
} else if (response.statusCode == 400) {
|
||||
// Bad request: OSRM couldn't parse coordinates. Fall back to a simple straight-line route.
|
||||
try {
|
||||
final distanceMeters = calculateDistance(start, end);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt(); // assume ~40 km/h -> 11.11 m/s
|
||||
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct');
|
||||
return NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}');
|
||||
}
|
||||
} else {
|
||||
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getRoute', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a route that passes through the provided stops in the given order.
|
||||
Future<NavigationRoute> getRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 400) {
|
||||
final distanceMeters = _sumDistancesBetween(stops);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(
|
||||
points: stops,
|
||||
distance: distanceMeters,
|
||||
duration: durationSec,
|
||||
summary: 'Direct fallback',
|
||||
);
|
||||
return NavigationRoute(
|
||||
start: stops.first,
|
||||
end: stops.last,
|
||||
legs: [leg],
|
||||
totalDistance: distanceMeters,
|
||||
totalDuration: durationSec,
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: stops.first,
|
||||
end: stops.last,
|
||||
);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getRouteThroughStops', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an optimized trip through multiple stops.
|
||||
/// Keeps the first and last stops fixed and lets OSRM order the intermediates.
|
||||
Future<OptimizedRouteResult> getOptimizedRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'https://router.project-osrm.org/trip/v1/driving/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 400) {
|
||||
// fallback: return simple ordered stops route
|
||||
final distanceMeters = _sumDistancesBetween(stops);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(points: stops, distance: distanceMeters, duration: durationSec, summary: 'Fallback optimized trip');
|
||||
final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
|
||||
return OptimizedRouteResult(route: route, orderedStops: stops);
|
||||
}
|
||||
throw Exception('Failed to get optimized route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final trips = data['trips'] as List?;
|
||||
if (trips == null || trips.isEmpty) {
|
||||
throw Exception('No optimized trip found');
|
||||
}
|
||||
|
||||
final trip = trips.first as Map<String, dynamic>;
|
||||
final geometry = trip['geometry'] as Map<String, dynamic>?;
|
||||
final coordinatesData = geometry?['coordinates'] as List? ?? [];
|
||||
final points = coordinatesData
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList();
|
||||
|
||||
final waypointItems = (data['waypoints'] as List?) ?? [];
|
||||
final orderedPairs = <MapEntry<int, NavigationPoint>>[];
|
||||
|
||||
for (var i = 0; i < waypointItems.length && i < stops.length; i++) {
|
||||
final waypoint = waypointItems[i];
|
||||
if (waypoint is! Map<String, dynamic>) continue;
|
||||
final index = waypoint['waypoint_index'] as num?;
|
||||
if (index == null) continue;
|
||||
orderedPairs.add(MapEntry(index.toInt(), stops[i]));
|
||||
}
|
||||
|
||||
orderedPairs.sort((a, b) => a.key.compareTo(b.key));
|
||||
final orderedStops = orderedPairs.isNotEmpty
|
||||
? orderedPairs.map((entry) => entry.value).toList()
|
||||
: stops;
|
||||
|
||||
final route = NavigationRoute(
|
||||
start: orderedStops.first,
|
||||
end: orderedStops.last,
|
||||
legs: [
|
||||
RouteLeg.fromPoints(
|
||||
points: points,
|
||||
distance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
duration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
summary: 'Optimized trip',
|
||||
),
|
||||
],
|
||||
totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
totalDuration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
return OptimizedRouteResult(
|
||||
route: route,
|
||||
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
|
||||
);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getOptimizedRouteThroughStops', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get multiple routes (alternatives)
|
||||
Future<List<NavigationRoute>> getMultipleRoutes({
|
||||
required NavigationPoint start,
|
||||
required NavigationPoint end,
|
||||
int alternatives = 2,
|
||||
}) async {
|
||||
try {
|
||||
final coordinates =
|
||||
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}';
|
||||
final url =
|
||||
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&alternatives=$alternatives';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routesData = data['routes'] as List?;
|
||||
if (routesData == null || routesData.isEmpty) {
|
||||
throw Exception('No routes found in response');
|
||||
}
|
||||
|
||||
final routes = routesData
|
||||
.map((routeData) {
|
||||
if (routeData is! Map<String, dynamic>) return null;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
})
|
||||
.whereType<NavigationRoute>()
|
||||
.toList();
|
||||
|
||||
return routes;
|
||||
} else if (response.statusCode == 400) {
|
||||
// fallback: single straight-line route
|
||||
final distanceMeters = calculateDistance(start, end);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct');
|
||||
return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)];
|
||||
} else {
|
||||
throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getMultipleRoutes', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
double _sumDistancesBetween(List<NavigationPoint> points) {
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
total += calculateDistance(points[i], points[i + 1]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Calculate straight-line distance between two points
|
||||
double calculateDistance(NavigationPoint p1, NavigationPoint p2) {
|
||||
const distance = Distance();
|
||||
return distance(
|
||||
LatLng(p1.latitude, p1.longitude),
|
||||
LatLng(p2.latitude, p2.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
/// Geocode a place name to coordinates using Nominatim (OpenStreetMap)
|
||||
Future<NavigationPoint> geocodePlace(String name) async {
|
||||
try {
|
||||
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
|
||||
'q': name,
|
||||
'format': 'json',
|
||||
'limit': '1',
|
||||
});
|
||||
|
||||
final response = await http.get(uri, headers: {
|
||||
'User-Agent': 'bus-terminal-app',
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Geocoding failed: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final list = jsonDecode(response.body) as List<dynamic>?;
|
||||
if (list == null || list.isEmpty) {
|
||||
throw Exception('No geocoding result for "$name"');
|
||||
}
|
||||
|
||||
final item = list[0] as Map<String, dynamic>;
|
||||
final lat = double.parse(item['lat'] as String);
|
||||
final lon = double.parse(item['lon'] as String);
|
||||
final display = item['display_name'] as String? ?? name;
|
||||
|
||||
return NavigationPoint(latitude: lat, longitude: lon, name: display);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.geocodePlace', e);
|
||||
throw Exception('Geocode error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/schedule.dart';
|
||||
import '../models/navigation.dart';
|
||||
import 'auth_service.dart';
|
||||
import 'error_log_service.dart';
|
||||
import 'exceptions.dart';
|
||||
|
||||
/// Schedule/Route Service
|
||||
class ScheduleService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
final _authService = AuthService();
|
||||
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get public schedule overview for a specific date.
|
||||
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final queryParameters = <String, String>{'date': dateStr};
|
||||
if (lineId != null) {
|
||||
queryParameters['lineId'] = lineId.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(queryParameters: queryParameters);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['schedules'] as List? ?? [])
|
||||
.map((schedule) => schedule as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load schedules: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getSchedules', e);
|
||||
throw Exception('Schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all recurring schedule templates, independent of a specific date.
|
||||
Future<List<ServiceSchedule>> getRecurringSchedules({int? lineId}) async {
|
||||
try {
|
||||
final queryParameters = <String, String>{};
|
||||
if (lineId != null) {
|
||||
queryParameters['lineId'] = lineId.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/recurring').replace(queryParameters: queryParameters);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['schedules'] as List? ?? [])
|
||||
.map((schedule) => schedule as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load recurring schedules: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getRecurringSchedules', e);
|
||||
throw Exception('Recurring schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stop list for a specific schedule.
|
||||
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$_apiBaseUrl/schedules/$scheduleId/stops'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['stops'] as List? ?? [])
|
||||
.map((stop) => stop as Map<String, dynamic>)
|
||||
.map(ScheduleStop.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load schedule stops: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getScheduleStops', e);
|
||||
throw Exception('Schedule stops error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get daily schedule for a specific date (includes recurring schedules)
|
||||
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// Call /schedules/overview which includes both one-time and recurring schedules for the date
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(
|
||||
queryParameters: {'date': dateStr}
|
||||
);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final schedules = (data['schedules'] as List? ?? [])
|
||||
.map((s) => s as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
|
||||
// Convert ServiceSchedule to Route objects for display
|
||||
final routes = schedules.map((schedule) {
|
||||
return Route(
|
||||
id: schedule.id,
|
||||
busLine: BusLine(
|
||||
id: schedule.busLineId,
|
||||
lineNumber: schedule.lineNumber,
|
||||
startStop: '',
|
||||
endStop: '',
|
||||
estimatedDuration: 60,
|
||||
description: '',
|
||||
),
|
||||
busType: BusType(
|
||||
id: schedule.busId,
|
||||
name: schedule.busName,
|
||||
seatCapacity: 0,
|
||||
licensePlate: '',
|
||||
),
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
status: schedule.status,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return DailySchedule(
|
||||
routes: routes,
|
||||
date: date,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to load schedule: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getDailySchedule', e);
|
||||
throw Exception('Schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get today's schedule
|
||||
Future<DailySchedule> getTodaySchedule() async {
|
||||
return getDailySchedule(DateTime.now());
|
||||
}
|
||||
|
||||
/// Get per-date progress for a schedule occurrence (public endpoint)
|
||||
Future<Map<String, dynamic>> getScheduleProgress(int scheduleId, DateTime date) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final uri = Uri.parse('$_apiBaseUrl/public/schedules/$scheduleId/progress').replace(queryParameters: {'date': dateStr});
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
throw Exception('Failed to load progress: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getScheduleProgress', e);
|
||||
throw Exception('Schedule progress error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get route details
|
||||
Future<Route> getRouteDetails(int routeId) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_apiBaseUrl/routes/$routeId'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return Route.fromJson(data);
|
||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else {
|
||||
final body = response.body;
|
||||
throw Exception('Failed to load route: ${response.statusCode} -- response body: $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.getRouteDetails', e);
|
||||
throw Exception('Route error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update route status
|
||||
Future<void> updateRouteStatus(int routeId, String status) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
// Transit API updates status at /routes/:routeId/status
|
||||
final response = await http.patch(
|
||||
Uri.parse('$_apiBaseUrl/routes/$routeId/status'),
|
||||
headers: headers,
|
||||
body: jsonEncode({'status': status}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.updateRouteStatus', e);
|
||||
throw Exception('Status update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update schedule status for driver-facing schedule workflows.
|
||||
Future<void> updateScheduleStatus(int scheduleId, String status, {DateTime? date}) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final body = <String, dynamic>{'status': status};
|
||||
if (date != null) {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
body['date'] = dateStr;
|
||||
}
|
||||
|
||||
final response = await http.patch(
|
||||
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'),
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update schedule status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.updateScheduleStatus', e);
|
||||
throw Exception('Schedule status update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stops for a bus line
|
||||
Future<List<NavigationPoint>> getStops(int busLineId) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final raw = data['stops'] as List? ?? [];
|
||||
final stops = raw.map((s) => s as Map<String, dynamic>).map((s) {
|
||||
final lat = (s['latitude'] ?? s['lat']) as num?;
|
||||
final lon = (s['longitude'] ?? s['lon']) as num?;
|
||||
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
|
||||
if (lat == null || lon == null) return null;
|
||||
return NavigationPoint(
|
||||
latitude: lat.toDouble(),
|
||||
longitude: lon.toDouble(),
|
||||
name: name,
|
||||
);
|
||||
}).whereType<NavigationPoint>().toList();
|
||||
|
||||
return stops;
|
||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else {
|
||||
final body = response.body;
|
||||
throw Exception('Failed to load stops: ${response.statusCode} -- response body: $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.getStops', e);
|
||||
throw Exception('Get stops error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a schedule stop as passed by the driver.
|
||||
Future<void> markScheduleStopPassed({
|
||||
required int scheduleId,
|
||||
required int stopOrder,
|
||||
DateTime? actualPassedAt,
|
||||
}) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final body = <String, dynamic>{};
|
||||
if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String();
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'),
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await _authService.logout();
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to mark stop as passed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.markScheduleStopPassed', e);
|
||||
throw Exception('Mark stop passed error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Material 3 Theme configuratie voor bus_terminal
|
||||
class AppTheme {
|
||||
static const Color _navy = Color(0xFF0F172A);
|
||||
static const Color _surface = Color(0xFFF8FAFC);
|
||||
static const Color _surfaceDark = Color(0xFF111827);
|
||||
static const Color _amber = Color(0xFFF59E0B);
|
||||
static const Color _cyan = Color(0xFF38BDF8);
|
||||
static const Color _success = Color(0xFF22C55E);
|
||||
static const Color _error = Color(0xFFEF4444);
|
||||
|
||||
static TextTheme _textTheme(ColorScheme colorScheme) {
|
||||
return TextTheme(
|
||||
displayLarge: const TextStyle(fontSize: 46, fontWeight: FontWeight.w800, letterSpacing: -1.2),
|
||||
displayMedium: const TextStyle(fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: -0.9),
|
||||
headlineLarge: const TextStyle(fontSize: 30, fontWeight: FontWeight.w800, letterSpacing: -0.4),
|
||||
headlineMedium: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.3),
|
||||
titleLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
titleMedium: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
titleSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||
bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: colorScheme.onSurface),
|
||||
bodyMedium: TextStyle(fontSize: 14, height: 1.45, color: colorScheme.onSurfaceVariant),
|
||||
bodySmall: TextStyle(fontSize: 12, height: 1.35, color: colorScheme.onSurfaceVariant),
|
||||
labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||
labelMedium: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
labelSmall: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.25),
|
||||
);
|
||||
}
|
||||
|
||||
static ColorScheme _buildLightScheme() {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: _cyan,
|
||||
brightness: Brightness.light,
|
||||
primary: _navy,
|
||||
secondary: _amber,
|
||||
tertiary: _cyan,
|
||||
surface: _surface,
|
||||
onSurface: _navy,
|
||||
error: _error,
|
||||
).copyWith(
|
||||
surfaceContainerHighest: const Color(0xFFE2E8F0),
|
||||
outline: const Color(0xFF94A3B8),
|
||||
);
|
||||
}
|
||||
|
||||
static ColorScheme _buildDarkScheme() {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: _cyan,
|
||||
brightness: Brightness.dark,
|
||||
primary: _cyan,
|
||||
secondary: _amber,
|
||||
tertiary: _success,
|
||||
surface: _surfaceDark,
|
||||
onSurface: Colors.white,
|
||||
error: _error,
|
||||
).copyWith(
|
||||
surfaceContainerHighest: const Color(0xFF1F2937),
|
||||
outline: const Color(0xFF334155),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData lightTheme() {
|
||||
final colorScheme = _buildLightScheme();
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF1F5F9),
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
textTheme: _textTheme(colorScheme),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colorScheme.surface,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colorScheme.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: colorScheme.outline.withOpacity(0.18), thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant.withOpacity(0.8)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondary,
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
side: BorderSide(color: colorScheme.outline.withOpacity(0.35)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: colorScheme.primary,
|
||||
linearTrackColor: colorScheme.primary.withOpacity(0.12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData darkTheme() {
|
||||
final colorScheme = _buildDarkScheme();
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFF0B1220),
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
textTheme: _textTheme(colorScheme).apply(bodyColor: Colors.white, displayColor: Colors.white),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: const Color(0xFF0B1220),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF111827),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: Color(0xFF243244), thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF111827),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
|
||||
hintStyle: const TextStyle(color: Color(0xFF94A3B8)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.black,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondary,
|
||||
foregroundColor: Colors.black,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
side: const BorderSide(color: Color(0xFF334155)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: const Color(0xFF1E293B),
|
||||
labelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
color: colorScheme.primary,
|
||||
linearTrackColor: colorScheme.primary.withOpacity(0.16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Custom input field optimized for bus drivers
|
||||
/// - Large touch targets (40+ height)
|
||||
/// - Clear visibility
|
||||
/// - Accessible design
|
||||
class CustomInputField extends StatefulWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final bool obscureText;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const CustomInputField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
this.obscureText = false,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomInputField> createState() => _CustomInputFieldState();
|
||||
}
|
||||
|
||||
class _CustomInputFieldState extends State<CustomInputField> {
|
||||
late bool _obscureText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_obscureText = widget.obscureText;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.label,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
obscureText: _obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
validator: widget.validator,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16, // Larger vertical padding for touch targets
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
widget.obscureText ? Icons.lock_outline : Icons.badge_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
suffixIcon: widget.obscureText
|
||||
? IconButton(
|
||||
tooltip: _obscureText ? 'Toon wachtwoord' : 'Verberg wachtwoord',
|
||||
onPressed: () {
|
||||
setState(() => _obscureText = !_obscureText);
|
||||
},
|
||||
icon: Icon(
|
||||
_obscureText ? Icons.visibility_off : Icons.visibility,
|
||||
size: 28, // Larger icon for accessibility
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
splashRadius: 24,
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/schedule.dart' as schedule;
|
||||
import '../screens/navigation_screen.dart';
|
||||
import '../services/schedule_service.dart';
|
||||
|
||||
/// Route card widget showing route details
|
||||
class RouteCard extends StatelessWidget {
|
||||
final schedule.Route route;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const RouteCard({
|
||||
super.key,
|
||||
required this.route,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status) {
|
||||
case 'scheduled':
|
||||
return Colors.blue;
|
||||
case 'in_progress':
|
||||
return Colors.orange;
|
||||
case 'completed':
|
||||
return Colors.green;
|
||||
case 'cancelled':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'scheduled':
|
||||
return 'Planned';
|
||||
case 'in_progress':
|
||||
return 'In Progress';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final statusColor = _getStatusColor(route.status);
|
||||
final startTimeStr = _formatTime(route.startTime);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
final scheduleService = ScheduleService();
|
||||
await scheduleService.updateRouteStatus(route.id, 'in_progress');
|
||||
} catch (_) {
|
||||
// Ignore status update errors here; navigation should still open.
|
||||
}
|
||||
|
||||
try {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NavigationScreen(route: route),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Surface navigation errors to the user so they can report/log.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Navigatiefout: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 0,
|
||||
color: colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: Line number + Status
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Line number - Large and prominent
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.primary, colorScheme.tertiary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Line ${route.busLine.lineNumber}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Status badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.14),
|
||||
border: Border.all(color: statusColor),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusLabel(route.status),
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Route info: Start -> End
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'From',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
route.busLine.startStop,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'To',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
route.busLine.endStop,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bus info and time
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Bus type
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Bus',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
'${route.busType.name} (${route.busType.seatCapacity} seats)',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
route.busType.licensePlate,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Start time
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'Start Time',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
startTimeStr,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${route.busLine.estimatedDuration} min duration',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "bus_terminal")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.bus_terminal")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the application ID.
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "my_application.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
g_autoptr(MyApplication) app = my_application_new();
|
||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "bus_terminal");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "bus_terminal");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(
|
||||
project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
GdkRGBA background_color;
|
||||
// Background defaults to black, override it here if necessary, e.g. #00000000
|
||||
// for transparent.
|
||||
gdk_rgba_parse(&background_color, "#000000");
|
||||
fl_view_set_background_color(view, &background_color);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
// Show the window when Flutter renders.
|
||||
// Requires the view to be realized so we can start rendering.
|
||||
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line =
|
||||
my_application_local_command_line;
|
||||
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication,
|
||||
my_application,
|
||||
MY,
|
||||
APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
||||
@@ -0,0 +1,7 @@
|
||||
# Flutter-related
|
||||
**/Flutter/ephemeral/
|
||||
**/Pods/
|
||||
|
||||
# Xcode-related
|
||||
**/dgph
|
||||
**/xcuserdata/
|
||||
@@ -0,0 +1 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import geolocator_apple
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||