diff --git a/.gitignore b/.gitignore index 50aec09..09b5d34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -increment_build_number.sh - # Do not remove or rename entries in this file, only add new ones # See https://github.com/flutter/flutter/issues/128635 for more context. diff --git a/.metadata b/.metadata index fbf1e56..7f3042e 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668" + revision: "b25305a8832cfc6ba632a7f87ad455e319dccce8" channel: "stable" project_type: app @@ -13,14 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 - base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 - - platform: ios - create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 - base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 - - platform: web - create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 - base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + - platform: android + create_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 + base_revision: b25305a8832cfc6ba632a7f87ad455e319dccce8 # User provided section diff --git a/README.md b/README.md index f3f16cd..464f55b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # CABO Counter -![Version](https://img.shields.io/badge/Version-0.1.3-orange) +![Version](https://img.shields.io/badge/Version-0.2.5-orange) ![Flutter](https://img.shields.io/badge/Flutter-3.24.5-blue?logo=flutter) ![Dart](https://img.shields.io/badge/Dart-3.5.4-blue?logo=dart) -![iOS](https://img.shields.io/badge/iOS-18.3.1-white?logo=apple) +![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple) ![GitHub Issues](https://img.shields.io/github/issues/flixcoo/Cabo-Counter?logo=github) ![GitHub Pull Requests](https://img.shields.io/github/issues-pr/flixcoo/Cabo-Counter?logo=github) ![GitHub Last Commit](https://img.shields.io/github/last-commit/flixcoo/Cabo-Counter?logo=github) diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..fa964b0 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "flixcoo.cabo_counter" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "flixcoo.cabo_counter" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ebcf66d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..db35381 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/kotlin/flixcoo/cabo_counter/MainActivity.kt b/android/app/src/main/kotlin/flixcoo/cabo_counter/MainActivity.kt new file mode 100644 index 0000000..2139efc --- /dev/null +++ b/android/app/src/main/kotlin/flixcoo/cabo_counter/MainActivity.kt @@ -0,0 +1,5 @@ +package flixcoo.cabo_counter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..9933510 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..9933510 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..aae21ce Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..7cb62a0 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..44cfaeb Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..dce96af Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..0163710 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..7af803a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d052c9a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5cd66b3 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..740ff80 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..ddffe3f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..2a2e4e5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c67377b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..90989d6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..20c9c09 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4fbcecd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c75fa72 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..2a1e274 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..47668b6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b9049c9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c83cd30 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..1249793 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..a37ba7a --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #FF101010 + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..a6b9d9b --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..39ba5b8 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a37ba7a --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF101010 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..7f2cb72 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/cabo-counter-logo_rounded.png b/assets/cabo-counter-logo_rounded.png deleted file mode 100644 index 68d0a13..0000000 Binary files a/assets/cabo-counter-logo_rounded.png and /dev/null differ diff --git a/assets/cabo_counter-logo_rounded.png b/assets/cabo_counter-logo_rounded.png new file mode 100644 index 0000000..90ffa5a Binary files /dev/null and b/assets/cabo_counter-logo_rounded.png differ diff --git a/assets/schema.json b/assets/schema.json new file mode 100644 index 0000000..17d7faa --- /dev/null +++ b/assets/schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for cabo game data", + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "gameTitle": { + "type": "string" + }, + "players": { + "type": "array", + "items": { + "type": "string" + } + }, + "pointLimit": { + "type": "number" + }, + "caboPenalty": { + "type": "number" + }, + "isPointsLimitEnabled": { + "type": "boolean" + }, + "isGameFinished": { + "type": "boolean" + }, + "winner": { + "type": "string" + }, + "roundNumber": { + "type": "number" + }, + "playerScores": { + "type": "array", + "items": { + "type": "number" + } + }, + "roundList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roundNum": { + "type": "number" + }, + "caboPlayerIndex": { + "type": "number" + }, + "kamikazePlayerIndex": { + "type": ["number", "null"] + }, + "scores": { + "type": "array", + "items": { + "type": "number" + } + }, + "scoreUpdates": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "roundNum", + "caboPlayerIndex", + "scores", + "scoreUpdates" + ] + } + } + }, + "required": [ + "createdAt", + "gameTitle", + "players", + "pointLimit", + "caboPenalty", + "isPointsLimitEnabled", + "isGameFinished", + "winner", + "roundNumber", + "playerScores", + "roundList" + ] + } +} \ No newline at end of file diff --git a/increment_build_number.sh b/increment_build_number.sh new file mode 100644 index 0000000..7d209bc --- /dev/null +++ b/increment_build_number.sh @@ -0,0 +1,2 @@ +build_number=$(awk -F'+' '/version:/ {print $2}' pubspec.yaml); build_number=${build_number:-0}; +sed -i '' -E "s/(version: [0-9]+\.[0-9]+\.[0-9]\+)[0-9]*/\1$((build_number + 1))/" pubspec.yaml \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 804f441..21a9acd 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,9 +13,9 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; BA5CEC53A2821E187DBB1FD7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0BC0DD8B3C0A792C3146B81 /* Pods_RunnerTests.framework */; }; D75B248D5087FCA1FE89A25D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C8C4A61D5CAF34BC96AB7335 /* Pods_Runner.framework */; }; + DCDAA4912DF6315D0037AD02 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DCDAA4902DF6315D0037AD02 /* Launch Screen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,11 +58,11 @@ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A1E03C9434E30A36490D45FD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; BD7B095CB3C8E1C485D27AF6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; C8C4A61D5CAF34BC96AB7335 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DCDAA4902DF6315D0037AD02 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; E5945FF93491A654655A9828 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; F0BC0DD8B3C0A792C3146B81 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -145,12 +145,12 @@ children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + DCDAA4902DF6315D0037AD02 /* Launch Screen.storyboard */, ); path = Runner; sourceTree = ""; @@ -259,9 +259,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + DCDAA4912DF6315D0037AD02 /* Launch Screen.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -400,14 +400,6 @@ name = Main.storyboard; sourceTree = ""; }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -469,6 +461,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -478,13 +471,13 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Cabo Counter"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.flixcoo.caboCounter; + PRODUCT_BUNDLE_IDENTIFIER = flixcoo.caboCounter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -666,6 +659,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -675,13 +669,13 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Cabo Counter"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.flixcoo.caboCounter; + PRODUCT_BUNDLE_IDENTIFIER = flixcoo.caboCounter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -701,6 +695,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -710,13 +705,13 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Cabo Counter"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.flixcoo.caboCounter; + PRODUCT_BUNDLE_IDENTIFIER = flixcoo.caboCounter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1728d10..52bde3b 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..d4f5a74 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,9 @@ - - + + + - - + + @@ -14,13 +15,14 @@ - + - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f992151..48dde5b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,46 +2,52 @@ - CADisableMinimumFrameDurationOnPhone + LSApplicationCategoryType + public.app-category.utilities + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + UIApplicationSupportsIndirectInputEvents + + CFBundleSignature + ???? CFBundleDisplayName Cabo Counter - CFBundleExecutable - $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 CFBundleName cabo_counter_app CFBundlePackageType APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleInfoDictionaryVersion + 6.0 + UILaunchStoryboardName + Launch Screen.storyboard + UIMainStoryboardFile + Main + NSPhotoLibraryUsageDescription + This app does not access your photo library. This message is required for technical reasons only. LSApplicationQueriesSchemes https http instagram - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main UIStatusBarHidden UIStatusBarStyle - UIStatusBarStyleDarkContent + UIStatusBarStyleLightContent UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/ios/Runner/Launch Screen.storyboard b/ios/Runner/Launch Screen.storyboard new file mode 100644 index 0000000..0e405e7 --- /dev/null +++ b/ios/Runner/Launch Screen.storyboard @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/data/game_manager.dart b/lib/data/game_manager.dart new file mode 100644 index 0000000..94b6287 --- /dev/null +++ b/lib/data/game_manager.dart @@ -0,0 +1,41 @@ +import 'package:cabo_counter/data/game_session.dart'; +import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:flutter/foundation.dart'; + +class GameManager extends ChangeNotifier { + List gameList = []; + + /// Adds a new game session to the list and sorts it by creation date. + /// Takes a [GameSession] object as input. It then adds the session to the `gameList`, + /// sorts the list in descending order based on the creation date, and notifies listeners of the change. + /// It also saves the updated game sessions to local storage. + /// Returns the index of the newly added session in the sorted list. + Future addGameSession(GameSession session) async { + session.addListener(() { + notifyListeners(); // Propagate session changes + }); + gameList.add(session); + print( + '[game_manager.dart] Added game session: ${session.gameTitle} at ${session.createdAt}'); + gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + print( + '[game_manager.dart] Sorted game sessions by creation date. Total sessions: ${gameList.length}'); + notifyListeners(); + await LocalStorageService.saveGameSessions(); + print('[game_manager.dart] Saved game sessions to local storage.'); + return gameList.indexOf(session); + } + + /// Removes a game session from the list and sorts it by creation date. + /// Takes a [index] as input. It then removes the session at the specified index from the `gameList`, + /// sorts the list in descending order based on the creation date, and notifies listeners of the change. + /// It also saves the updated game sessions to local storage. + void removeGameSession(int index) { + gameList[index].removeListener(notifyListeners); + gameList.removeAt(index); + notifyListeners(); + LocalStorageService.saveGameSessions(); + } +} + +final gameManager = GameManager(); diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index 0403822..4896e02 100644 --- a/lib/data/game_session.dart +++ b/lib/data/game_session.dart @@ -1,43 +1,77 @@ import 'package:cabo_counter/data/round.dart'; +import 'package:flutter/cupertino.dart'; /// This class represents a game session for Cabo game. /// [createdAt] is the timestamp of when the game session was created. /// [gameTitle] is the title of the game. -/// [gameHasPointLimit] is a boolean indicating if the game has the default +/// [isPointsLimitEnabled] is a boolean indicating if the game has the default /// point limit of 101 points or not. /// [players] is a string list of player names. /// [playerScores] is a list of the summed scores of all players. /// [roundNumber] is the current round number. /// [isGameFinished] is a boolean indicating if the game has ended yet. /// [winner] is the name of the player who won the game. -class GameSession { - final DateTime createdAt = DateTime.now(); +class GameSession extends ChangeNotifier { + final DateTime createdAt; final String gameTitle; - final bool gameHasPointLimit; final List players; - late List playerScores; - List roundList = []; - int roundNumber = 1; + final int pointLimit; + final int caboPenalty; + final bool isPointsLimitEnabled; bool isGameFinished = false; String winner = ''; + int roundNumber = 1; + late List playerScores; + List roundList = []; GameSession({ + required this.createdAt, required this.gameTitle, - required this.gameHasPointLimit, required this.players, + required this.pointLimit, + required this.caboPenalty, + required this.isPointsLimitEnabled, }) { playerScores = List.filled(players.length, 0); } @override - String toString() { + toString() { return ('GameSession: [createdAt: $createdAt, gameTitle: $gameTitle, ' - 'gameHasPointLimit: $gameHasPointLimit, players: $players, ' - 'playerScores: $playerScores, roundList: $roundList, ' - 'roundNumber: $roundNumber, isGameFinished: $isGameFinished, ' - 'winner: $winner]'); + 'isPointsLimitEnabled: $isPointsLimitEnabled, pointLimit: $pointLimit, caboPenalty: $caboPenalty,' + ' players: $players, playerScores: $playerScores, roundList: $roundList, winner: $winner]'); } + /// Converts the GameSession object to a JSON map. + Map toJson() => { + 'createdAt': createdAt.toIso8601String(), + 'gameTitle': gameTitle, + 'players': players, + 'pointLimit': pointLimit, + 'caboPenalty': caboPenalty, + 'isPointsLimitEnabled': isPointsLimitEnabled, + 'isGameFinished': isGameFinished, + 'winner': winner, + 'roundNumber': roundNumber, + 'playerScores': playerScores, + 'roundList': roundList.map((e) => e.toJson()).toList() + }; + + /// Creates a GameSession object from a JSON map. + GameSession.fromJson(Map json) + : createdAt = DateTime.parse(json['createdAt']), + gameTitle = json['gameTitle'], + players = List.from(json['players']), + pointLimit = json['pointLimit'], + caboPenalty = json['caboPenalty'], + isPointsLimitEnabled = json['isPointsLimitEnabled'], + isGameFinished = json['isGameFinished'], + winner = json['winner'], + roundNumber = json['roundNumber'], + playerScores = List.from(json['playerScores']), + roundList = + (json['roundList'] as List).map((e) => Round.fromJson(e)).toList(); + /// Returns the length of all player names combined. int getLengthOfPlayerNames() { int length = 0; @@ -52,13 +86,13 @@ class GameSession { void applyKamikaze(int roundNum, int kamikazePlayerIndex) { List roundScores = List.generate(players.length, (_) => 0); List scoreUpdates = List.generate(players.length, (_) => 0); - for (int i = 0; i < roundScores.length; i++) { + for (int i = 0; i < scoreUpdates.length; i++) { if (i != kamikazePlayerIndex) { scoreUpdates[i] += 50; } } addRoundScoresToList( - roundNum, roundScores, scoreUpdates, kamikazePlayerIndex); + roundNum, roundScores, scoreUpdates, 0, kamikazePlayerIndex); } /// Checks the scores of the current round and assigns points to the players. @@ -92,7 +126,7 @@ class GameSession { print('${players[caboPlayerIndex]} hat CABO gesagt ' 'und bekommt 0 Punkte'); print('Alle anderen Spieler bekommen ihre Punkte'); - _assignPoints(roundNum, roundScores, [caboPlayerIndex]); + _assignPoints(roundNum, roundScores, caboPlayerIndex, [caboPlayerIndex]); } else { // A player other than the one who said CABO has the fewest points. print('${players[caboPlayerIndex]} hat CABO gesagt, ' @@ -101,10 +135,16 @@ class GameSession { for (int i in lowestScoreIndex) { print('${players[i]}: ${roundScores[i]} Punkte'); } - _assignPoints(roundNum, roundScores, lowestScoreIndex, caboPlayerIndex); + _assignPoints(roundNum, roundScores, caboPlayerIndex, lowestScoreIndex, + caboPlayerIndex); } } + /// The _getLowestScoreIndex method but forwarded for testing purposes. + @visibleForTesting + List testingGetLowestScoreIndex(List roundScores) => + _getLowestScoreIndex(roundScores); + /// Returns the index of the player with the lowest score. If there are /// multiple players with the same lowest score, all of them are returned. /// [roundScores] is a list of the scores of all players in the current round. @@ -123,12 +163,19 @@ class GameSession { return lowestScoreIndex; } + @visibleForTesting + void testingAssignPoints(int roundNum, List roundScores, + int caboPlayerIndex, List winnerIndex, [int? loserIndex]) => + _assignPoints( + roundNum, roundScores, caboPlayerIndex, winnerIndex, loserIndex); + /// Assigns points to the players based on the scores of the current round. /// [roundNum] is the number of the current round. /// [roundScores] is the raw list of the scores of all players in the current round. /// [winnerIndex] is the index of the player who receives 5 extra points - void _assignPoints(int roundNum, List roundScores, List winnerIndex, - [int loserIndex = -1]) { + void _assignPoints(int roundNum, List roundScores, int caboPlayerIndex, + List winnerIndex, + [int? loserIndex]) { /// List of the updates for every player score List scoreUpdates = [...roundScores]; print('Folgende Punkte wurden aus der Runde übernommen:'); @@ -139,7 +186,7 @@ class GameSession { print('${players[i]} hat gewonnen und bekommt 0 Punkte'); scoreUpdates[i] = 0; } - if (loserIndex != -1) { + if (loserIndex != null) { print('${players[loserIndex]} bekommt 5 Fehlerpunkte'); scoreUpdates[loserIndex] += 5; } @@ -148,7 +195,7 @@ class GameSession { print('${players[i]}: ${scoreUpdates[i]}'); } print('scoreUpdates: $scoreUpdates, roundScores: $roundScores'); - addRoundScoresToList(roundNum, roundScores, scoreUpdates); + addRoundScoresToList(roundNum, roundScores, scoreUpdates, caboPlayerIndex); } /// Sets the scores of the players for a specific round. @@ -157,19 +204,25 @@ class GameSession { /// playerScores. Its important that each index of the [roundScores] list /// corresponds to the index of the player in the [playerScores] list. void addRoundScoresToList( - int roundNum, List roundScores, List scoreUpdates, - [int? kamikazePlayerIndex]) { + int roundNum, + List roundScores, + List scoreUpdates, + int caboPlayerIndex, [ + int? kamikazePlayerIndex, + ]) { Round newRound = Round( roundNum: roundNum, + caboPlayerIndex: caboPlayerIndex, + kamikazePlayerIndex: kamikazePlayerIndex, scores: roundScores, scoreUpdates: scoreUpdates, - kamikazePlayerIndex: kamikazePlayerIndex, ); if (roundNum > roundList.length) { roundList.add(newRound); } else { roundList[roundNum - 1] = newRound; } + notifyListeners(); } /// This method updates the points of each player after a round. @@ -182,13 +235,13 @@ class GameSession { /// It then checks if any player has exceeded 100 points. If so, it sets /// isGameFinished to true and calls the _setWinner() method to determine /// the winner. - void updatePoints() { + Future updatePoints() async { _sumPoints(); - if (gameHasPointLimit) { + if (isPointsLimitEnabled) { _checkHundredPointsReached(); for (int i = 0; i < playerScores.length; i++) { - if (playerScores[i] > 100) { + if (playerScores[i] > pointLimit) { isGameFinished = true; print('${players[i]} hat die 100 Punkte ueberschritten, ' 'deswegen wurde das Spiel beendet'); @@ -196,8 +249,12 @@ class GameSession { } } } + notifyListeners(); } + @visibleForTesting + void testingSumPoints() => _sumPoints(); + /// Sums up the points of all players and stores the result in the /// playerScores list. void _sumPoints() { @@ -207,6 +264,7 @@ class GameSession { playerScores[i] += roundList[j].scoreUpdates[i]; } } + notifyListeners(); } /// Checks if a player has reached 100 points in the current round. @@ -214,7 +272,7 @@ class GameSession { /// the corresponding round update. void _checkHundredPointsReached() { for (int i = 0; i < players.length; i++) { - if (playerScores[i] == 100) { + if (playerScores[i] == pointLimit) { print('${players[i]} hat genau 100 Punkte erreicht und bekommt ' 'deswegen 50 Punkte abgezogen'); roundList[roundNumber - 1].scoreUpdates[i] -= 50; @@ -236,10 +294,14 @@ class GameSession { } } winner = lowestPlayer; + notifyListeners(); } /// Increases the round number by 1. void increaseRound() { roundNumber++; + print('roundNumber erhöht: $roundNumber — Hash: ${identityHashCode(this)}'); + + notifyListeners(); } } diff --git a/lib/data/round.dart b/lib/data/round.dart index 1f03b33..dcc5d9f 100644 --- a/lib/data/round.dart +++ b/lib/data/round.dart @@ -1,3 +1,5 @@ +import 'package:cabo_counter/data/game_session.dart'; + /// This class represents a single round in the game. /// It is stored within the [GameSession] class. /// [roundNum] is the number of the round its reppresenting. @@ -7,13 +9,40 @@ /// kamikaze, this value is null. class Round { final int roundNum; + final int caboPlayerIndex; + final int? kamikazePlayerIndex; final List scores; final List scoreUpdates; - final int? kamikazePlayerIndex; - Round( - {required this.roundNum, - required this.scores, - required this.scoreUpdates, - this.kamikazePlayerIndex}); + Round({ + required this.roundNum, + required this.caboPlayerIndex, + this.kamikazePlayerIndex, + required this.scores, + required this.scoreUpdates, + }); + + @override + toString() { + return 'Round $roundNum, caboPlayerIndex: $caboPlayerIndex, ' + 'kamikazePlayerIndex: $kamikazePlayerIndex, scores: $scores, ' + 'scoreUpdates: $scoreUpdates, '; + } + + /// Converts the Round object to a JSON map. + Map toJson() => { + 'roundNum': roundNum, + 'caboPlayerIndex': caboPlayerIndex, + 'kamikazePlayerIndex': kamikazePlayerIndex, + 'scores': scores, + 'scoreUpdates': scoreUpdates, + }; + + /// Creates a Round object from a JSON map. + Round.fromJson(Map json) + : roundNum = json['roundNum'], + caboPlayerIndex = json['caboPlayerIndex'], + kamikazePlayerIndex = json['kamikazePlayerIndex'], + scores = List.from(json['scores']), + scoreUpdates = List.from(json['scoreUpdates']); } diff --git a/lib/main.dart b/lib/main.dart index 9580339..9cc3648 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,28 +1,66 @@ -import 'package:cabo_counter/utility/theme.dart' as theme; -import 'package:cabo_counter/views/main_menu_view.dart'; +import 'package:cabo_counter/services/config_service.dart'; +import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:cabo_counter/utility/custom_theme.dart'; +import 'package:cabo_counter/utility/globals.dart'; +import 'package:cabo_counter/views/tab_view.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations( + [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); + await ConfigService.initConfig(); + Globals.pointLimit = await ConfigService.getPointLimit(); + Globals.caboPenalty = await ConfigService.getCaboPenalty(); runApp(const App()); } -class App extends StatelessWidget { +class App extends StatefulWidget { const App({super.key}); + @override + State createState() => _AppState(); +} + +class _AppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + LocalStorageService.loadGameSessions(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + LocalStorageService.saveGameSessions(); + } + } + @override Widget build(BuildContext context) { + LocalStorageService.loadGameSessions(); + return CupertinoApp( theme: CupertinoThemeData( brightness: Brightness.dark, - primaryColor: theme.primaryColor, - scaffoldBackgroundColor: theme.backgroundColor, + primaryColor: CustomTheme.primaryColor, + scaffoldBackgroundColor: CustomTheme.backgroundColor, textTheme: CupertinoTextThemeData( - primaryColor: theme.primaryColor, + primaryColor: CustomTheme.primaryColor, ), ), debugShowCheckedModeBanner: false, title: 'Cabo Counter', - home: const MainMenuView(), + home: const TabView(), ); } } diff --git a/lib/services/config_service.dart b/lib/services/config_service.dart new file mode 100644 index 0000000..1c8275a --- /dev/null +++ b/lib/services/config_service.dart @@ -0,0 +1,57 @@ +import 'package:cabo_counter/utility/globals.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// This class handles the configuration settings for the app. +/// It uses SharedPreferences to store and retrieve the personal configuration of the app. +/// Currently it provides methods to initialize, get, and set the point limit and cabo penalty. +class ConfigService { + static const String _keyPointLimit = 'pointLimit'; + static const String _keyCaboPenalty = 'caboPenalty'; + static const int _defaultPointLimit = 100; // Default Value + static const int _defaultCaboPenalty = 5; // Default Value + + static Future initConfig() async { + final prefs = await SharedPreferences.getInstance(); + + // Default values only set if they are not already set + prefs.setInt( + _keyPointLimit, prefs.getInt(_keyPointLimit) ?? _defaultPointLimit); + prefs.setInt( + _keyCaboPenalty, prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty); + } + + /// Getter for the point limit. + static Future getPointLimit() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_keyPointLimit) ?? _defaultPointLimit; + } + + /// Setter for the point limit. + /// [newPointLimit] is the new point limit to be set. + static Future setPointLimit(int newPointLimit) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyPointLimit, newPointLimit); + } + + /// Getter for the cabo penalty. + static Future getCaboPenalty() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty; + } + + /// Setter for the cabo penalty. + /// [newCaboPenalty] is the new cabo penalty to be set. + static Future setCaboPenalty(int newCaboPenalty) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyCaboPenalty, newCaboPenalty); + } + + /// Resets the configuration to default values. + static Future resetConfig() async { + Globals.pointLimit = _defaultPointLimit; + Globals.caboPenalty = _defaultCaboPenalty; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyPointLimit, _defaultPointLimit); + await prefs.setInt(_keyCaboPenalty, _defaultCaboPenalty); + } +} diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart new file mode 100644 index 0000000..e3fddcc --- /dev/null +++ b/lib/services/local_storage_service.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cabo_counter/data/game_manager.dart'; +import 'package:cabo_counter/data/game_session.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/services.dart'; +import 'package:json_schema/json_schema.dart'; +import 'package:path_provider/path_provider.dart'; + +class LocalStorageService { + static const String _fileName = 'game_data.json'; + + /// Writes the game session list to a JSON file and returns it as string. + static String getJsonFile() { + final jsonFile = + gameManager.gameList.map((session) => session.toJson()).toList(); + return json.encode(jsonFile); + } + + /// Returns the path to the local JSON file. + static Future _getFilePath() async { + final directory = await getApplicationDocumentsDirectory(); + final path = '${directory.path}/$_fileName'; + return File(path); + } + + /// Saves the game sessions to a local JSON file. + static Future saveGameSessions() async { + print('[local_storage_service.dart] Versuche, Daten zu speichern...'); + try { + final file = await _getFilePath(); + final jsonFile = getJsonFile(); + await file.writeAsString(jsonFile); + print( + '[local_storage_service.dart] Die Spieldaten wurden zwischengespeichert.'); + } catch (e) { + print( + '[local_storage_service.dart] Fehler beim Speichern der Spieldaten. Exception: $e'); + } + } + + /// Loads the game data from a local JSON file. + static Future loadGameSessions() async { + print('[local_storage_service.dart] Versuche, Daten zu laden...'); + try { + final file = await _getFilePath(); + + if (!await file.exists()) { + print( + '[local_storage_service.dart] Es existiert noch keine Datei mit Spieldaten'); + return false; + } + + print( + '[local_storage_service.dart] Es existiert bereits eine Datei mit Spieldaten'); + final jsonString = await file.readAsString(); + + if (jsonString.isEmpty) { + print('[local_storage_service.dart] Die gefundene Datei ist leer'); + return false; + } + + if (!await validateJsonSchema(jsonString)) { + print( + '[local_storage_service.dart] Die Datei konnte nicht validiert werden'); + gameManager.gameList = []; + return false; + } + print('[local_storage_service.dart] Die gefundene Datei hat Inhalt'); + print( + '[local_storage_service.dart] Die gefundene Datei wurde erfolgreich validiert'); + final jsonList = json.decode(jsonString) as List; + + gameManager.gameList = jsonList + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + + print( + '[local_storage_service.dart] Die Spieldaten wurden erfolgreich geladen und verarbeitet'); + return true; + } catch (e) { + print( + '[local_storage_service.dart] Fehler beim Laden der Spieldaten:\n$e'); + gameManager.gameList = []; + return false; + } + } + + /// Opens the file picker to save a JSON file with the current game data. + static Future exportJsonFile() async { + final jsonString = getJsonFile(); + try { + final bytes = Uint8List.fromList(utf8.encode(jsonString)); + final result = await FileSaver.instance.saveAs( + name: 'cabo_counter_data', + bytes: bytes, + ext: 'json', + mimeType: MimeType.json, + ); + print( + '[local_storage_service.dart] Die Spieldaten wurden exportiert. Dateipfad: $result'); + return true; + } catch (e) { + print( + '[local_storage_service.dart] Fehler beim Exportieren der Spieldaten. Exception: $e'); + return false; + } + } + + /// Opens the file picker to import a JSON file and loads the game data from it. + static Future importJsonFile() async { + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Wähle eine Datei mit Spieldaten aus', + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result == null) { + print( + '[local_storage_service.dart] Der Filepicker-Dialog wurde abgebrochen'); + return false; + } + + try { + final jsonString = await _readFileContent(result.files.single); + + if (!await validateJsonSchema(jsonString)) { + return false; + } + final jsonData = json.decode(jsonString) as List; + gameManager.gameList = jsonData + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + print( + '[local_storage_service.dart] Die Datei wurde erfolgreich Importiertn'); + return true; + } on FormatException catch (e) { + print( + '[local_storage_service.dart] Ungültiges JSON-Format. Exception: $e'); + return false; + } on Exception catch (e) { + print( + '[local_storage_service.dart] Fehler beim Dateizugriff. Exception: $e'); + return false; + } + } + + /// Helper method to read file content from either bytes or path + static Future _readFileContent(PlatformFile file) async { + if (file.bytes != null) return utf8.decode(file.bytes!); + if (file.path != null) return await File(file.path!).readAsString(); + + throw Exception('Die Datei hat keinen lesbaren Inhalt'); + } + + /// Validates the JSON data against the schema. + static Future validateJsonSchema(String jsonString) async { + try { + final schemaString = await rootBundle.loadString('assets/schema.json'); + final schema = JsonSchema.create(json.decode(schemaString)); + final jsonData = json.decode(jsonString); + final result = schema.validate(jsonData); + + if (result.isValid) { + print('[local_storage_service.dart] JSON ist erfolgreich validiert.'); + return true; + } + print( + '[local_storage_service.dart] JSON ist nicht gültig.\nFehler: ${result.errors}'); + return false; + } catch (e) { + print( + '[local_storage_service.dart] Fehler beim Validieren des JSON-Schemas: $e'); + return false; + } + } + + static Future deleteAllGames() async { + try { + gameManager.gameList.clear(); + await saveGameSessions(); + print( + '[local_storage_service.dart] Alle Runden wurden erfolgreich gelöscht.'); + return true; + } catch (e) { + print( + '[local_storage_service.dart] Fehler beim Löschen aller Runden: $e'); + return false; + } + } +} diff --git a/lib/utility/styles.dart b/lib/utility/custom_theme.dart similarity index 55% rename from lib/utility/styles.dart rename to lib/utility/custom_theme.dart index d0f2921..77a2f5b 100644 --- a/lib/utility/styles.dart +++ b/lib/utility/custom_theme.dart @@ -1,8 +1,10 @@ import 'package:flutter/cupertino.dart'; -abstract class Styles { +class CustomTheme { + static Color white = CupertinoColors.white; static Color primaryColor = CupertinoColors.systemGreen; - static Color backgroundColor = const Color(0xFF080808); + static Color backgroundColor = const Color(0xFF101010); + static Color backgroundTintColor = CupertinoColors.darkBackgroundGray; static TextStyle modeTitle = TextStyle( color: primaryColor, @@ -14,21 +16,15 @@ abstract class Styles { fontSize: 16, ); - static TextStyle createGameTitle = TextStyle( + static TextStyle rowTitle = TextStyle( fontSize: 20, color: primaryColor, fontWeight: FontWeight.bold, ); - static TextStyle roundTitle = const TextStyle( + static TextStyle roundTitle = TextStyle( fontSize: 60, - color: CupertinoColors.white, - fontWeight: FontWeight.bold, - ); - - static TextStyle roundPlayers = const TextStyle( - fontSize: 20, - color: CupertinoColors.white, + color: white, fontWeight: FontWeight.bold, ); } diff --git a/lib/utility/globals.dart b/lib/utility/globals.dart index 2d22891..54cf2d0 100644 --- a/lib/utility/globals.dart +++ b/lib/utility/globals.dart @@ -1,5 +1,5 @@ -import 'package:cabo_counter/data/game_session.dart'; - class Globals { - static Map gamesMap = {}; + static int pointLimit = 100; + static int caboPenalty = 5; + static String appDevPhase = 'Beta'; } diff --git a/lib/utility/theme.dart b/lib/utility/theme.dart deleted file mode 100644 index 6b4f963..0000000 --- a/lib/utility/theme.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -Color white = CupertinoColors.white; -Color primaryColor = CupertinoColors.systemGreen; -Color backgroundColor = const Color(0xFF101010); -Color backgroundTintColor = CupertinoColors.darkBackgroundGray; - -TextStyle modeTitle = TextStyle( - color: primaryColor, - fontSize: 20, - fontWeight: FontWeight.bold, -); - -const TextStyle modeDescription = TextStyle( - fontSize: 16, -); - -TextStyle createGameTitle = TextStyle( - fontSize: 20, - color: primaryColor, - fontWeight: FontWeight.bold, -); - -TextStyle roundTitle = const TextStyle( - fontSize: 60, - color: CupertinoColors.white, - fontWeight: FontWeight.bold, -); - -TextStyle roundPlayers = const TextStyle( - fontSize: 20, - color: CupertinoColors.white, - fontWeight: FontWeight.bold, -); diff --git a/lib/views/active_game_view.dart b/lib/views/active_game_view.dart index 59d3a1b..ae513d4 100644 --- a/lib/views/active_game_view.dart +++ b/lib/views/active_game_view.dart @@ -1,5 +1,5 @@ import 'package:cabo_counter/data/game_session.dart'; -import 'package:cabo_counter/utility/theme.dart' as theme; +import 'package:cabo_counter/utility/custom_theme.dart'; import 'package:cabo_counter/views/round_view.dart'; import 'package:flutter/cupertino.dart'; @@ -15,91 +15,98 @@ class ActiveGameView extends StatefulWidget { class _ActiveGameViewState extends State { @override Widget build(BuildContext context) { - List sortedPlayerIndices = _getSortedPlayerIndices(); - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(widget.gameSession.gameTitle), - ), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), - child: Text( - 'Spieler:innen', - style: theme.createGameTitle, + return ListenableBuilder( + listenable: widget.gameSession, + builder: (context, _) { + List sortedPlayerIndices = _getSortedPlayerIndices(); + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(widget.gameSession.gameTitle), + ), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + 'Spieler:innen', + style: CustomTheme.rowTitle, + ), + ), + ListView.builder( + shrinkWrap: true, + itemCount: widget.gameSession.players.length, + itemBuilder: (BuildContext context, int index) { + int playerIndex = sortedPlayerIndices[index]; + return CupertinoListTile( + title: Row( + children: [ + _getPlacementPrefix(index), + const SizedBox(width: 5), + Text( + widget.gameSession.players[playerIndex], + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + trailing: Row( + children: [ + const SizedBox(width: 5), + Text( + '${widget.gameSession.playerScores[playerIndex]} ' + 'Punkte') + ], + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + 'Runden', + style: CustomTheme.rowTitle, + ), + ), + ListView.builder( + shrinkWrap: true, + itemCount: widget.gameSession.roundNumber, + itemBuilder: (BuildContext context, int index) { + return Padding( + padding: const EdgeInsets.all(1), + child: CupertinoListTile( + title: Text( + 'Runde ${index + 1}', + ), + trailing: index + 1 != + widget.gameSession.roundNumber || + widget.gameSession.isGameFinished == true + ? (const Text('\u{2705}', + style: TextStyle(fontSize: 22))) + : const Text('\u{23F3}', + style: TextStyle(fontSize: 22)), + onTap: () async { + // ignore: unused_local_variable + final val = await Navigator.of(context, + rootNavigator: true) + .push( + CupertinoPageRoute( + fullscreenDialog: true, + builder: (context) => RoundView( + gameSession: widget.gameSession, + roundNumber: index + 1), + ), + ); + }, + )); + }, + ), + ], ), ), - ListView.builder( - shrinkWrap: true, - itemCount: widget.gameSession.players.length, - itemBuilder: (BuildContext context, int index) { - int playerIndex = sortedPlayerIndices[index]; - return CupertinoListTile( - title: Row( - children: [ - _getPlacementPrefix(index), - const SizedBox(width: 5), - Text( - widget.gameSession.players[playerIndex], - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - trailing: Row( - children: [ - const SizedBox(width: 5), - Text('${widget.gameSession.playerScores[playerIndex]} ' - 'Punkte') - ], - ), - ); - }, - ), - Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), - child: Text( - 'Runden', - style: theme.createGameTitle, - ), - ), - ListView.builder( - shrinkWrap: true, - itemCount: widget.gameSession.roundNumber, - itemBuilder: (BuildContext context, int index) { - return Padding( - padding: const EdgeInsets.all(1), - child: CupertinoListTile( - title: Text( - 'Runde ${index + 1}', - ), - trailing: index + 1 != widget.gameSession.roundNumber || - widget.gameSession.isGameFinished == true - ? (const Text('\u{2705}', - style: TextStyle(fontSize: 22))) - : const Text('\u{23F3}', - style: TextStyle(fontSize: 22)), - onTap: () async { - // ignore: unused_local_variable - final val = await Navigator.push( - context, - CupertinoPageRoute( - fullscreenDialog: true, - builder: (context) => RoundView( - gameSession: widget.gameSession, - roundNumber: index + 1), - ), - ); - setState(() {}); - }, - )); - }, - ), - ], - ), - ), - ); + ); + }); } /// Returns a list of player indices sorted by their scores in diff --git a/lib/views/create_game_view.dart b/lib/views/create_game_view.dart index 2ffbe22..565d68a 100644 --- a/lib/views/create_game_view.dart +++ b/lib/views/create_game_view.dart @@ -1,5 +1,7 @@ +import 'package:cabo_counter/data/game_manager.dart'; import 'package:cabo_counter/data/game_session.dart'; -import 'package:cabo_counter/utility/styles.dart'; +import 'package:cabo_counter/utility/custom_theme.dart'; +import 'package:cabo_counter/utility/globals.dart'; import 'package:cabo_counter/views/active_game_view.dart'; import 'package:cabo_counter/views/mode_selection_view.dart'; import 'package:flutter/cupertino.dart'; @@ -42,11 +44,11 @@ class _CreateGameState extends State { padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), child: Text( 'Spiel', - style: Styles.createGameTitle, + style: CustomTheme.rowTitle, ), ), Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), + padding: const EdgeInsets.fromLTRB(15, 10, 10, 0), child: CupertinoTextField( decoration: const BoxDecoration(), maxLength: 16, @@ -58,7 +60,7 @@ class _CreateGameState extends State { ), // Spielmodus-Auswahl mit Chevron Padding( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), + padding: const EdgeInsets.fromLTRB(15, 10, 10, 0), child: CupertinoTextField( decoration: const BoxDecoration(), readOnly: true, @@ -75,15 +77,15 @@ class _CreateGameState extends State { ], ), onTap: () async { - // Öffne das Modus-Auswahlmenü final selected = await Navigator.push( context, CupertinoPageRoute( - builder: (context) => const ModeSelectionMenu(), + builder: (context) => ModeSelectionMenu( + pointLimit: Globals.pointLimit, + ), ), ); - // Aktualisiere den ausgewählten Modus if (selected != null) { setState(() { selectedMode = selected; @@ -96,7 +98,7 @@ class _CreateGameState extends State { padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), child: Text( 'Spieler:innen', - style: Styles.createGameTitle, + style: CustomTheme.rowTitle, ), ), Expanded( @@ -204,7 +206,7 @@ class _CreateGameState extends State { ), ], ), - onPressed: () { + onPressed: () async { if (_gameTitleTextController.text == '') { showCupertinoDialog( context: context, @@ -273,7 +275,6 @@ class _CreateGameState extends State { ], ), ); - return; } List players = []; @@ -281,15 +282,24 @@ class _CreateGameState extends State { players.add(controller.text); } GameSession gameSession = GameSession( + createdAt: DateTime.now(), gameTitle: _gameTitleTextController.text, players: players, - gameHasPointLimit: selectedMode!, + pointLimit: Globals.pointLimit, + caboPenalty: Globals.caboPenalty, + isPointsLimitEnabled: selectedMode!, ); - Navigator.pushReplacement( - context, - CupertinoPageRoute( - builder: (context) => - ActiveGameView(gameSession: gameSession))); + final index = await gameManager.addGameSession(gameSession); + print('index des spiels: $index'); + if (context.mounted) { + Navigator.pushReplacement( + context, + CupertinoPageRoute( + builder: (context) => ActiveGameView( + gameSession: gameManager.gameList[index]))); + } else { + print('Context is not mounted'); + } }, ), ), diff --git a/lib/views/information_view.dart b/lib/views/information_view.dart index 667d180..e5c0833 100644 --- a/lib/views/information_view.dart +++ b/lib/views/information_view.dart @@ -1,113 +1,78 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; class InformationView extends StatelessWidget { const InformationView({super.key}); - Future _getPackageInfo() async { - return await PackageInfo.fromPlatform(); - } - @override Widget build(BuildContext context) { return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, navigationBar: const CupertinoNavigationBar( middle: Text('Über'), ), child: SafeArea( - child: Stack( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Padding( - padding: EdgeInsets.fromLTRB(0, 10, 0, 0), - child: Text( - 'Cabo Counter', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), + const Padding( + padding: EdgeInsets.fromLTRB(0, 10, 0, 0), + child: Text( + 'Cabo Counter', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: SizedBox( - height: 200, - child: - Image.asset('assets/cabo-counter-logo_rounded.png'), - )), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 30), - child: Text( - 'Hey :) Danke, dass du als eine:r der ersten User ' - 'meiner ersten eigenen App dabei bist! Ich hab sehr ' - 'viel Arbeit in dieses Projekt gesteckt und auch, ' - 'wenn ich (hoffentlich) an vieles Gedacht hab, wird ' - 'auf jeden Fall noch nicht alles 100% funktionieren. ' - 'Solltest du also irgendwelche Fehler entdecken oder ' - 'Feedback zum Design oder der Benutzerfreundlichkeit' - ' haben, zögere bitte nicht sie mir auf den dir ' - 'bekannten Wegen mitzuteilen. Danke! ', - textAlign: TextAlign.center, - softWrap: true, - )), - const SizedBox( - height: 30, - ), - const Text( - '\u00A9 Felix Kirchner', - style: TextStyle(fontSize: 16), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () => launchUrl( - Uri.parse('https://www.instagram.com/fx.kr')), - icon: const Icon(FontAwesomeIcons.instagram)), - IconButton( - onPressed: () => launchUrl( - Uri.parse('mailto:felix.kirchner.fk@gmail.com')), - icon: const Icon(CupertinoIcons.envelope)), - IconButton( - onPressed: () => launchUrl( - Uri.parse('https://www.github.com/flixcoo')), - icon: const Icon(FontAwesomeIcons.github)), - ], - ) - ], - ), - Positioned( - bottom: 30, - left: 0, - right: 0, - child: FutureBuilder( - future: _getPackageInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - 'Alpha ${snapshot.data!.version} ' - '(Build ${snapshot.data!.buildNumber})', - textAlign: TextAlign.center, - ); - } else if (snapshot.hasError) { - return const Text( - 'App-Version -.-.- (Build -)', - textAlign: TextAlign.center, - ); - } - return const Text( - 'Lade Version...', - textAlign: TextAlign.center, - ); - }, ), ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + child: SizedBox( + height: 200, + child: Image.asset('assets/cabo_counter-logo_rounded.png'), + )), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 30), + child: Text( + 'Hey :) Danke, dass du als eine:r der ersten User ' + 'meiner ersten eigenen App dabei bist! Ich hab sehr ' + 'viel Arbeit in dieses Projekt gesteckt und auch, ' + 'wenn ich (hoffentlich) an vieles Gedacht hab, wird ' + 'auf jeden Fall noch nicht alles 100% funktionieren. ' + 'Solltest du also irgendwelche Fehler entdecken oder ' + 'Feedback zum Design oder der Benutzerfreundlichkeit' + ' haben, zögere bitte nicht sie mir auf den dir ' + 'bekannten Wegen mitzuteilen. Danke! ', + textAlign: TextAlign.center, + softWrap: true, + )), + const SizedBox( + height: 30, + ), + const Text( + '\u00A9 Felix Kirchner', + style: TextStyle(fontSize: 16), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () => + launchUrl(Uri.parse('https://www.instagram.com/fx.kr')), + icon: const Icon(FontAwesomeIcons.instagram)), + IconButton( + onPressed: () => launchUrl( + Uri.parse('mailto:felix.kirchner.fk@gmail.com')), + icon: const Icon(CupertinoIcons.envelope)), + IconButton( + onPressed: () => + launchUrl(Uri.parse('https://www.github.com/flixcoo')), + icon: const Icon(FontAwesomeIcons.github)), + ], + ), ], ))); } diff --git a/lib/views/main_menu_view.dart b/lib/views/main_menu_view.dart index d89a91e..7c7e019 100644 --- a/lib/views/main_menu_view.dart +++ b/lib/views/main_menu_view.dart @@ -1,7 +1,9 @@ -import 'package:cabo_counter/data/game_session.dart'; +import 'package:cabo_counter/data/game_manager.dart'; +import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:cabo_counter/utility/custom_theme.dart'; import 'package:cabo_counter/views/active_game_view.dart'; import 'package:cabo_counter/views/create_game_view.dart'; -import 'package:cabo_counter/views/information_view.dart'; +import 'package:cabo_counter/views/settings_view.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -14,125 +16,214 @@ class MainMenuView extends StatefulWidget { } class _MainMenuViewState extends State { - final List gameSessionArray = [ - GameSession( - gameTitle: 'Spiel am 27.02.2025', - players: ['Clara', 'Tobias', 'Yannik', 'Lena', 'Lekaia'], - gameHasPointLimit: true), - GameSession( - gameTitle: 'Freundschaftsrunde', - players: ['Felix', 'Jonas', 'Nils'], - gameHasPointLimit: false), - GameSession( - gameTitle: 'Familienabend', - players: ['Mama', 'Papa', 'Lisa'], - gameHasPointLimit: true, - ), - GameSession( - gameTitle: 'Turnier 1. Runde', - players: ['Tim', 'Max', 'Sophie', 'Lena'], - gameHasPointLimit: false), - GameSession( - gameTitle: '2 Namen max length', - players: ['Heinrich', 'Johannes'], - gameHasPointLimit: true), - GameSession( - gameTitle: '3 Namen max length', - players: ['Benjamin', 'Stefanie', 'Wolfgang'], - gameHasPointLimit: false), - GameSession( - gameTitle: '4 Namen max length', - players: ['Leonhard', 'Mathilde', 'Bernhard', 'Gerlinde'], - gameHasPointLimit: true), - GameSession( - gameTitle: '5 Namen max length', - players: ['Hartmuth', 'Elisabet', 'Rosalind', 'Theresia', 'Karoline'], - gameHasPointLimit: false), - ]; + bool _isLoading = true; + + @override + initState() { + super.initState(); + LocalStorageService.loadGameSessions().then((_) { + setState(() { + _isLoading = false; + }); + }); + gameManager.addListener(_updateView); + } + + void _updateView() { + if (mounted) setState(() {}); + } @override Widget build(BuildContext context) { - gameSessionArray.sort((b, a) => a.createdAt.compareTo(b.createdAt)); - - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - leading: IconButton( - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => const InformationView(), - ), - ); - }, - icon: const Icon( - CupertinoIcons.info_circle, - size: 30, - )), - middle: const Text('Cabo Counter'), - trailing: IconButton( - onPressed: () { - Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => const CreateGame(), - ), - ); - }, - icon: const Icon(CupertinoIcons.add)), - ), - child: CupertinoPageScaffold( - child: SafeArea( - child: ListView.builder( - itemCount: gameSessionArray.length, - itemBuilder: (context, index) { - final session = gameSessionArray[index]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: CupertinoListTile( - title: Text(session.gameTitle), - subtitle: session.isGameFinished == true - ? Text( - '\u{1F947} ${session.winner}', - style: const TextStyle(fontSize: 14), - ) - : Text( - 'Modus: ${_translateGameMode(session.gameHasPointLimit)}', - style: const TextStyle(fontSize: 14), + return ListenableBuilder( + listenable: gameManager, + builder: (context, _) { + return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, + navigationBar: CupertinoNavigationBar( + leading: IconButton( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (context) => const SettingsView(), + ), + ); + }, + icon: const Icon(CupertinoIcons.settings, size: 30)), + middle: const Text('Cabo Counter'), + trailing: IconButton( + onPressed: () => { + Navigator.push( + context, + CupertinoPageRoute( + builder: (context) => const CreateGame(), ), - trailing: Row( - children: [ - Text('${session.roundNumber}'), - const SizedBox(width: 3), - const Icon( - CupertinoIcons.arrow_2_circlepath_circle_fill), - const SizedBox(width: 15), - Text('${session.players.length}'), - const SizedBox(width: 3), - const Icon(CupertinoIcons.person_2_fill), - ], - ), - onTap: () async { - //ignore: unused_local_variable - final val = await Navigator.push( - context, - CupertinoPageRoute( - builder: (context) => ActiveGameView( - gameSession: gameSessionArray[index]), - ), - ); - setState(() {}); - }, - )); - }, - ), - ), - ), - ); + ) + }, + icon: const Icon(CupertinoIcons.add)), + ), + child: CupertinoPageScaffold( + child: SafeArea( + child: _isLoading + ? const Center(child: CupertinoActivityIndicator()) + : gameManager.gameList.isEmpty + ? Column( + mainAxisAlignment: + MainAxisAlignment.center, // Oben ausrichten + children: [ + const SizedBox(height: 30), // Abstand von oben + Center( + child: GestureDetector( + onTap: () => setState(() {}), + child: Icon( + CupertinoIcons.plus, + size: 60, + color: CustomTheme.primaryColor, + ), + )), + const SizedBox(height: 10), // Abstand von oben + const Padding( + padding: EdgeInsets.symmetric(horizontal: 70), + child: Text( + 'Ganz schön leer hier...\nFüge über den Button oben rechts eine neue Runde hinzu.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ], + ) + : ListView.builder( + itemCount: gameManager.gameList.length, + itemBuilder: (context, index) { + final session = gameManager.gameList[index]; + return ListenableBuilder( + listenable: session, + builder: (context, _) { + return Dismissible( + key: Key(session.gameTitle), + background: Container( + color: CupertinoColors.destructiveRed, + alignment: Alignment.centerLeft, + padding: + const EdgeInsets.only(left: 20.0), + child: const Icon( + CupertinoIcons.delete, + color: CupertinoColors.white, + ), + ), + direction: DismissDirection.startToEnd, + confirmDismiss: (direction) async { + final String gameTitle = gameManager + .gameList[index].gameTitle; + return await _showDeleteGamePopup( + gameTitle); + }, + onDismissed: (direction) { + gameManager.removeGameSession(index); + }, + dismissThresholds: const { + DismissDirection.startToEnd: 0.6 + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10.0), + child: CupertinoListTile( + backgroundColorActivated: + CustomTheme.backgroundColor, + title: Text(session.gameTitle), + subtitle: + session.isGameFinished == true + ? Text( + '\u{1F947} ${session.winner}', + style: const TextStyle( + fontSize: 14), + ) + : Text( + 'Modus: ${_translateGameMode(session.isPointsLimitEnabled)}', + style: const TextStyle( + fontSize: 14), + ), + trailing: Row( + children: [ + Text('${session.roundNumber}'), + const SizedBox(width: 3), + const Icon(CupertinoIcons + .arrow_2_circlepath_circle_fill), + const SizedBox(width: 15), + Text('${session.players.length}'), + const SizedBox(width: 3), + const Icon( + CupertinoIcons.person_2_fill), + ], + ), + onTap: () async { + //ignore: unused_local_variable + final val = await Navigator.push( + context, + CupertinoPageRoute( + builder: (context) => + ActiveGameView( + gameSession: gameManager + .gameList[index]), + ), + ); + setState(() {}); + }, + ), + ), + ); + }); + }, + ), + ), + ), + ); + }); } + /// Translates the game mode boolean into the corresponding String. + /// If [pointLimit] is true, it returns '101 Punkte', otherwise it returns 'Unbegrenzt'. String _translateGameMode(bool pointLimit) { if (pointLimit) return '101 Punkte'; return 'Unbegrenzt'; } + + /// Shows a confirmation dialog to delete all game sessions. + /// Returns true if the user confirms the deletion, false otherwise. + /// [gameTitle] is the title of the game session to be deleted. + Future _showDeleteGamePopup(String gameTitle) async { + bool? shouldDelete = await showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('Spiel löschen?'), + content: Text( + 'Bist du sicher, dass du die Runde "$gameTitle" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.'), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Abbrechen'), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Löschen'), + ), + ], + ); + }, + ) ?? + false; + return shouldDelete; + } + + @override + void dispose() { + gameManager.removeListener(_updateView); + super.dispose(); + } } diff --git a/lib/views/mode_selection_view.dart b/lib/views/mode_selection_view.dart index 13a4b99..f4edf37 100644 --- a/lib/views/mode_selection_view.dart +++ b/lib/views/mode_selection_view.dart @@ -1,9 +1,9 @@ -import 'package:cabo_counter/utility/styles.dart'; -import 'package:cabo_counter/utility/theme.dart' as theme; +import 'package:cabo_counter/utility/custom_theme.dart'; import 'package:flutter/cupertino.dart'; class ModeSelectionMenu extends StatelessWidget { - const ModeSelectionMenu({super.key}); + final int pointLimit; + const ModeSelectionMenu({super.key, required this.pointLimit}); @override Widget build(BuildContext context) { @@ -16,10 +16,10 @@ class ModeSelectionMenu extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(0, 16, 0, 0), child: CupertinoListTile( - title: Text('101 Punkte', style: Styles.modeTitle), - subtitle: const Text( - 'Es wird solange gespielt, bis einer Spieler mehr als 100 Punkte erreicht', - style: Styles.modeDescription, + title: Text('$pointLimit Punkte', style: CustomTheme.modeTitle), + subtitle: Text( + 'Es wird solange gespielt, bis einer Spieler mehr als $pointLimit Punkte erreicht', + style: CustomTheme.modeDescription, maxLines: 3, ), onTap: () { @@ -30,11 +30,11 @@ class ModeSelectionMenu extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: CupertinoListTile( - title: Text('Unbegrenzt', style: theme.modeTitle), + title: Text('Unbegrenzt', style: CustomTheme.modeTitle), subtitle: const Text( 'Dem Spiel sind keine Grenzen gesetzt. Es wird so lange ' 'gespielt, bis Ihr keine Lust mehr habt.', - style: Styles.modeDescription, + style: CustomTheme.modeDescription, maxLines: 3, ), onTap: () { diff --git a/lib/views/round_view.dart b/lib/views/round_view.dart index 9520aa5..988adbf 100644 --- a/lib/views/round_view.dart +++ b/lib/views/round_view.dart @@ -1,5 +1,6 @@ import 'package:cabo_counter/data/game_session.dart'; -import 'package:cabo_counter/utility/theme.dart' as theme; +import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:cabo_counter/utility/custom_theme.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; @@ -52,9 +53,12 @@ class _RoundViewState extends State { _scoreControllerList[i].text = gameSession.roundList[widget.roundNumber - 1].scores[i].toString(); } + _caboPlayerIndex = + gameSession.roundList[widget.roundNumber - 1].caboPlayerIndex; _kamikazePlayerIndex = gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex; } + super.initState(); } @@ -71,7 +75,10 @@ class _RoundViewState extends State { previousPageTitle: 'Übersicht', leading: CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context, widget.gameSession), + onPressed: () => { + LocalStorageService.saveGameSessions(), + Navigator.pop(context, widget.gameSession) + }, child: const Text('Abbrechen'), ), ), @@ -86,7 +93,7 @@ class _RoundViewState extends State { children: [ const SizedBox(height: 40), Text('Runde ${widget.roundNumber}', - style: theme.roundTitle), + style: CustomTheme.roundTitle), const SizedBox(height: 10), const Text( 'Wer hat CABO gesagt?', @@ -101,8 +108,8 @@ class _RoundViewState extends State { child: SizedBox( height: 40, child: CupertinoSegmentedControl( - unselectedColor: theme.backgroundTintColor, - selectedColor: theme.primaryColor, + unselectedColor: CustomTheme.backgroundTintColor, + selectedColor: CustomTheme.primaryColor, groupValue: _caboPlayerIndex, children: Map.fromEntries(widget.gameSession.players .asMap() @@ -158,9 +165,9 @@ class _RoundViewState extends State { SizedBox( width: 100, child: Center(child: Text('Punkte'))), - SizedBox(width: 28), + SizedBox(width: 20), SizedBox( - width: 70, + width: 80, child: Center(child: Text('Kamikaze'))), ], ), @@ -209,6 +216,7 @@ class _RoundViewState extends State { textAlign: TextAlign.center, onSubmitted: (_) => _focusNextTextfield(index), + onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 50), @@ -267,7 +275,7 @@ class _RoundViewState extends State { return Container( height: 80, padding: const EdgeInsets.only(bottom: 20), - color: theme.backgroundTintColor, + color: CustomTheme.backgroundTintColor, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -275,6 +283,7 @@ class _RoundViewState extends State { onPressed: _areRoundInputsValid() ? () { _finishRound(); + LocalStorageService.saveGameSessions(); Navigator.pop(context, widget.gameSession); } : null, @@ -284,11 +293,12 @@ class _RoundViewState extends State { onPressed: _areRoundInputsValid() ? () { _finishRound(); + LocalStorageService.saveGameSessions(); if (widget.gameSession.isGameFinished == true) { Navigator.pop(context, widget.gameSession); } else { - Navigator.pushReplacement( - context, + Navigator.of(context, rootNavigator: true) + .pushReplacement( CupertinoPageRoute( builder: (context) => RoundView( gameSession: widget.gameSession, diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart new file mode 100644 index 0000000..8f0dcf4 --- /dev/null +++ b/lib/views/settings_view.dart @@ -0,0 +1,230 @@ +import 'package:cabo_counter/services/config_service.dart'; +import 'package:cabo_counter/services/local_storage_service.dart'; +import 'package:cabo_counter/utility/custom_theme.dart'; +import 'package:cabo_counter/utility/globals.dart'; +import 'package:cabo_counter/widgets/stepper.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsView extends StatefulWidget { + const SettingsView({super.key}); + + @override + State createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { + UniqueKey _stepperKey1 = UniqueKey(); + UniqueKey _stepperKey2 = UniqueKey(); + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Einstellungen'), + ), + child: SafeArea( + child: Stack( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + 'Punkte', + style: CustomTheme.rowTitle, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(15, 10, 10, 0), + child: CupertinoListTile( + padding: EdgeInsets.zero, + title: const Text('Cabo-Strafe'), + subtitle: const Text('... für falsches Cabo sagen'), + trailing: Stepper( + key: _stepperKey1, + initialValue: Globals.caboPenalty, + minValue: 0, + maxValue: 50, + step: 1, + onChanged: (newCaboPenalty) { + setState(() { + ConfigService.setCaboPenalty(newCaboPenalty); + Globals.caboPenalty = newCaboPenalty; + }); + }, + ), + )), + Padding( + padding: const EdgeInsets.fromLTRB(15, 10, 10, 0), + child: CupertinoListTile( + padding: EdgeInsets.zero, + title: const Text('Punkte-Limit'), + subtitle: const Text('... hier ist Schluss'), + trailing: Stepper( + key: _stepperKey2, + initialValue: Globals.pointLimit, + minValue: 30, + maxValue: 1000, + step: 10, + onChanged: (newPointLimit) { + setState(() { + ConfigService.setPointLimit(newPointLimit); + Globals.pointLimit = newPointLimit; + }); + }, + ), + )), + Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), + child: Center( + heightFactor: 0.9, + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => setState(() { + ConfigService.resetConfig(); + _stepperKey1 = UniqueKey(); + _stepperKey2 = UniqueKey(); + }), + child: const Text('Standard zurücksetzten'), + ), + )), + Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), + child: Text( + 'Spieldaten', + style: CustomTheme.rowTitle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 30), + child: Center( + heightFactor: 1, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoButton( + color: CustomTheme.primaryColor, + sizeStyle: CupertinoButtonSize.medium, + child: Text( + 'Daten exportieren', + style: + TextStyle(color: CustomTheme.backgroundColor), + ), + onPressed: () async { + print('Export pressed'); + final success = + await LocalStorageService.exportJsonFile(); + if (!success && context.mounted) { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('Fehler'), + content: const Text( + 'Datei konnte nicht exportiert werden.'), + actions: [ + CupertinoDialogAction( + child: const Text('OK'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + }, + ), + const SizedBox( + width: 20, + ), + CupertinoButton( + color: CustomTheme.primaryColor, + sizeStyle: CupertinoButtonSize.medium, + child: Text( + 'Daten importieren', + style: + TextStyle(color: CustomTheme.backgroundColor), + ), + onPressed: () async { + print('Import pressed'); + final success = + await LocalStorageService.importJsonFile(); + if (!success && context.mounted) { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('Fehler'), + content: const Text( + 'Datei konnte nicht importiert werden.'), + actions: [ + CupertinoDialogAction( + child: const Text('OK'), + onPressed: () => + Navigator.pop(context), + ), + ], + )); + } + }), + ], + )), + ) + ], + ), + Positioned( + bottom: 30, + left: 0, + right: 0, + child: Column( + children: [ + const Center( + child: Text('Fehler gefunden?'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 30), + child: Center( + child: CupertinoButton( + onPressed: () => launchUrl(Uri.parse( + 'https://github.com/flixcoo/Cabo-Counter/issues')), + child: const Text('Issue erstellen'), + ), + ), + ), + FutureBuilder( + future: _getPackageInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + '${Globals.appDevPhase} ${snapshot.data!.version} ' + '(Build ${snapshot.data!.buildNumber})', + textAlign: TextAlign.center, + ); + } else if (snapshot.hasError) { + return const Text( + 'App-Version -.-.- (Build -)', + textAlign: TextAlign.center, + ); + } + return const Text( + 'Lade Version...', + textAlign: TextAlign.center, + ); + }, + ) + ], + )), + ], + )), + ); + } + + Future _getPackageInfo() async { + return await PackageInfo.fromPlatform(); + } +} diff --git a/lib/views/tab_view.dart b/lib/views/tab_view.dart new file mode 100644 index 0000000..a0b5821 --- /dev/null +++ b/lib/views/tab_view.dart @@ -0,0 +1,47 @@ +import 'package:cabo_counter/utility/custom_theme.dart'; +import 'package:cabo_counter/views/information_view.dart'; +import 'package:cabo_counter/views/main_menu_view.dart'; +import 'package:flutter/cupertino.dart'; + +class TabView extends StatefulWidget { + const TabView({super.key}); + + @override + // ignore: library_private_types_in_public_api + _TabViewState createState() => _TabViewState(); +} + +class _TabViewState extends State { + @override + Widget build(BuildContext context) { + return CupertinoTabScaffold( + tabBar: CupertinoTabBar( + backgroundColor: CustomTheme.backgroundTintColor, + iconSize: 27, + height: 55, + items: const [ + BottomNavigationBarItem( + icon: Icon( + CupertinoIcons.house_fill, + ), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon( + CupertinoIcons.info, + ), + label: 'About', + ), + ]), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView(builder: (BuildContext context) { + if (index == 0) { + return const MainMenuView(); + } else { + return const InformationView(); + } + }); + }, + ); + } +} diff --git a/lib/widgets/stepper.dart b/lib/widgets/stepper.dart new file mode 100644 index 0000000..879235e --- /dev/null +++ b/lib/widgets/stepper.dart @@ -0,0 +1,73 @@ +import 'package:flutter/cupertino.dart'; // Für iOS-Style + +class Stepper extends StatefulWidget { + final int minValue; + final int maxValue; + final int? initialValue; + final int step; + final ValueChanged onChanged; + const Stepper({ + super.key, + required this.minValue, + required this.maxValue, + required this.step, + required this.onChanged, + this.initialValue, + }); + + @override + // ignore: library_private_types_in_public_api + _StepperState createState() => _StepperState(); +} + +class _StepperState extends State { + late int _value; + + @override + void initState() { + super.initState(); + final start = widget.initialValue ?? widget.minValue; + _value = start.clamp(widget.minValue, widget.maxValue); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.all(8), + onPressed: _decrement, + child: const Icon(CupertinoIcons.minus), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text('$_value', style: const TextStyle(fontSize: 18)), + ), + CupertinoButton( + padding: const EdgeInsets.all(8), + onPressed: _increment, + child: const Icon(CupertinoIcons.add), + ), + ], + ); + } + + void _increment() { + if (_value + widget.step <= widget.maxValue) { + setState(() { + _value += widget.step; + widget.onChanged.call(_value); + }); + } + } + + void _decrement() { + if (_value - widget.step >= widget.minValue) { + setState(() { + _value -= widget.step; + widget.onChanged.call(_value); + }); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 326e7ee..0000be1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: cabo_counter description: "Mobile app for the card game Cabo" publish_to: 'none' -version: 0.1.3+65 +version: 0.2.5+195 environment: sdk: ^3.5.4 @@ -11,20 +11,26 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 - font_awesome_flutter: ^10.8.0 - url_launcher: any - package_info_plus: any + file_picker: ^10.1.2 + file_saver: ^0.2.6 flutter_keyboard_visibility: ^6.0.0 + font_awesome_flutter: ^10.8.0 + package_info_plus: any + path_provider: ^2.1.1 + typed_data: ^1.3.2 + url_launcher: any + json_schema: ^5.2.1 + shared_preferences: ^2.5.3 + logger: ^2.5.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 test: ^1.25.15 - flutter: uses-material-design: false assets: - - assets/cabo-counter-logo_rounded.png + - assets/cabo_counter-logo_rounded.png + - assets/schema.json diff --git a/test/data/game_session_test.dart b/test/data/game_session_test.dart new file mode 100644 index 0000000..0e9bfa1 --- /dev/null +++ b/test/data/game_session_test.dart @@ -0,0 +1,164 @@ +import 'package:cabo_counter/data/game_session.dart'; +import 'package:test/test.dart'; + +void main() { + late GameSession session; + final testPlayers = ['Alice', 'Bob', 'Charlie']; + final testDate = DateTime(2023, 1, 1); + const testTitle = 'Test Game'; + + setUp(() { + session = GameSession( + createdAt: testDate, + gameTitle: testTitle, + players: testPlayers, + pointLimit: 100, + caboPenalty: 5, + isPointsLimitEnabled: true, + ); + }); + + group('Initialization & JSON', () { + test('Initialization', () { + expect(session.gameTitle, testTitle); + expect(session.players, testPlayers); + expect(session.playerScores, [0, 0, 0]); + expect(session.roundNumber, 1); + expect(session.isGameFinished, isFalse); + expect(session.winner, isEmpty); + }); + + test('toJson and fromJson', () { + // Add some rounds to test serialization + session.addRoundScoresToList(1, [10, 20, 30], [10, 20, 30], 0); + session.addRoundScoresToList(2, [15, 25, 35], [5, 5, 5], 1); + + final json = session.toJson(); + final fromJsonSession = GameSession.fromJson(json); + + expect(fromJsonSession.gameTitle, testTitle); + expect(fromJsonSession.players, testPlayers); + expect(fromJsonSession.roundList.length, 2); + }); + + test('null values in JSON', () { + expect( + () => GameSession.fromJson({ + 'createdAt': testDate.toIso8601String(), + 'gameTitle': null, // Invalid + 'players': testPlayers, + 'pointLimit': 100, + 'caboPenalty': 50, + 'isPointsLimitEnabled': true, + 'isGameFinished': false, + 'winner': '', + 'roundNumber': 1, + 'playerScores': [0, 0, 0], + 'roundList': [], + }), + throwsA(isA())); + }); + }); + + group('Helper Functions', () { + test('getLengthOfPlayerNames', () { + expect(session.getLengthOfPlayerNames(), + equals(15)); // Alice(5) + Bob(3) + Charlie(7) + }); + + test('increaseRound', () { + expect(session.roundNumber, 1); + session.increaseRound(); + expect(session.roundNumber, 2); + }); + + test('getLowestScoreIndex', () { + List lowestScoreIndex; + + lowestScoreIndex = session.testingGetLowestScoreIndex([5, 10, 15]); + expect(lowestScoreIndex, [0]); + + lowestScoreIndex = session.testingGetLowestScoreIndex([5, 5, 15]); + expect(lowestScoreIndex, [0, 1]); + + lowestScoreIndex = session.testingGetLowestScoreIndex([5, 5, 5]); + expect(lowestScoreIndex, [0, 1, 2]); + }); + }); + + group('Game Functions', () { + test('applyKamikaze', () { + session.applyKamikaze(1, 0); // Alice has kamikaze + expect(session.roundList[0].scoreUpdates, [0, 50, 50]); + expect(session.roundList[0].scores, [0, 0, 0]); + expect(session.roundList[0].kamikazePlayerIndex, 0); + expect(session.roundList[0].caboPlayerIndex, 0); + }); + + test('calculateScoredPoints - CABO player has lowest', () { + session.calculateScoredPoints(1, [3, 5, 8], 0); // Alice has lowest + expect(session.roundList[0].scoreUpdates, equals([0, 5, 8])); + }); + + test('calculateScoredPoints - CABO player not lowest', () { + session.calculateScoredPoints(1, [5, 3, 8], 0); // Bob has lowest + expect(session.roundList[0].scoreUpdates, [10, 0, 8]); + }); + + test('addRoundScoresToList', () { + session.addRoundScoresToList(1, [3, 5, 8], [0, 5, 8], 0); + expect(session.roundList.length, 1); + expect(session.roundList[0].roundNum, 1); + expect(session.roundList[0].scoreUpdates, [0, 5, 8]); + expect(session.roundList[0].scores, [3, 5, 8]); + expect(session.roundList[0].kamikazePlayerIndex, isNull); + expect(session.roundList[0].caboPlayerIndex, 0); + }); + + test('updatePoints - game not finished', () async { + session.addRoundScoresToList(1, [10, 20, 30], [10, 20, 30], 0); + await session.updatePoints(); + expect(session.isGameFinished, isFalse); + }); + + test('updatePoints - game finished', () async { + session.addRoundScoresToList(1, [101, 20, 30], [101, 20, 30], 0); + await session.updatePoints(); + expect(session.isGameFinished, isTrue); + }); + + test('_assignPoints', () { + // Alice said Cabo and has the lowest score + session.testingAssignPoints(1, [5, 10, 15], 0, [0]); + expect(session.roundList[0].scoreUpdates, [0, 10, 15]); + + // Alice said Cabo and has not the lowest score + session.testingAssignPoints(1, [5, 10, 15], 0, [1], 0); + expect(session.roundList[0].scoreUpdates, [10, 0, 15]); + + // Bob and Charlie have the lowest score, Alice said Cabo + session.testingAssignPoints(1, [15, 5, 5], 0, [1, 2], 0); + expect(session.roundList[0].scoreUpdates, [20, 0, 0]); + }); + + test('_sumPoints', () async { + session.addRoundScoresToList(1, [10, 20, 30], [10, 20, 30], 0); + session.addRoundScoresToList(2, [5, 5, 5], [5, 5, 5], 1); + session.testingSumPoints(); + expect(session.playerScores, [15, 25, 35]); + }); + + test('_checkHundredPointsReached via updatePoints', () { + session.addRoundScoresToList(1, [50, 5, 15], [50, 0, 15], 1); + session.addRoundScoresToList(2, [50, 5, 15], [50, 0, 15], 1); + session.updatePoints(); + expect(session.playerScores, equals([50, 0, 30])); + }); + + test('_setWinner via updatePoints', () async { + session.addRoundScoresToList(1, [101, 20, 30], [101, 0, 30], 1); + await session.updatePoints(); + expect(session.winner, 'Bob'); // Bob has lowest score (20) + }); + }); +} diff --git a/test/data/round_test.dart b/test/data/round_test.dart new file mode 100644 index 0000000..3e5ef4e --- /dev/null +++ b/test/data/round_test.dart @@ -0,0 +1,117 @@ +import 'package:cabo_counter/data/round.dart'; +import 'package:test/test.dart'; + +void main() { + late Round round; + const testRoundNum = 1; + const testCaboPlayerIndex = 0; + const testKamikazePlayerIndex = 1; + const testScores = [10, 20, 30]; + const testScoreUpdates = [5, 15, 25]; + + setUp(() { + round = Round( + roundNum: testRoundNum, + caboPlayerIndex: testCaboPlayerIndex, + kamikazePlayerIndex: testKamikazePlayerIndex, + scores: testScores, + scoreUpdates: testScoreUpdates, + ); + }); + + group('Constructor Tests', () { + test('Constructor sets correct values', () { + expect(round.roundNum, testRoundNum); + expect(round.caboPlayerIndex, testCaboPlayerIndex); + expect(round.kamikazePlayerIndex, testKamikazePlayerIndex); + expect(round.scores, testScores); + expect(round.scoreUpdates, testScoreUpdates); + }); + + test('Constructor with null kamikazePlayerIndex', () { + final roundWithoutKamikaze = Round( + roundNum: testRoundNum, + caboPlayerIndex: testCaboPlayerIndex, + kamikazePlayerIndex: null, + scores: testScores, + scoreUpdates: testScoreUpdates, + ); + + expect(roundWithoutKamikaze.kamikazePlayerIndex, isNull); + }); + }); + + group('JSON Methods', () { + test('toJson() returns correct map', () { + final jsonMap = round.toJson(); + + expect(jsonMap['roundNum'], equals(testRoundNum)); + expect(jsonMap['caboPlayerIndex'], equals(testCaboPlayerIndex)); + expect(jsonMap['kamikazePlayerIndex'], equals(testKamikazePlayerIndex)); + expect(jsonMap['scores'], equals(testScores)); + expect(jsonMap['scoreUpdates'], equals(testScoreUpdates)); + }); + + test('fromJson() creates correct Round object', () { + final jsonMap = { + 'roundNum': testRoundNum, + 'caboPlayerIndex': testCaboPlayerIndex, + 'kamikazePlayerIndex': testKamikazePlayerIndex, + 'scores': testScores, + 'scoreUpdates': testScoreUpdates, + }; + + final fromJsonRound = Round.fromJson(jsonMap); + + expect(fromJsonRound.roundNum, testRoundNum); + expect(fromJsonRound.caboPlayerIndex, testCaboPlayerIndex); + expect(fromJsonRound.kamikazePlayerIndex, testKamikazePlayerIndex); + expect(fromJsonRound.scores, testScores); + expect(fromJsonRound.scoreUpdates, testScoreUpdates); + }); + + test('fromJson() with null kamikazePlayerIndex', () { + final jsonMap = { + 'roundNum': testRoundNum, + 'caboPlayerIndex': testCaboPlayerIndex, + 'kamikazePlayerIndex': null, + 'scores': testScores, + 'scoreUpdates': testScoreUpdates, + }; + + final fromJsonRound = Round.fromJson(jsonMap); + + expect(fromJsonRound.kamikazePlayerIndex, isNull); + }); + }); + + group('toString()', () { + test('toString() returns correct string representation', () { + final expectedString = 'Round $testRoundNum, ' + 'caboPlayerIndex: $testCaboPlayerIndex, ' + 'kamikazePlayerIndex: $testKamikazePlayerIndex, ' + 'scores: $testScores, ' + 'scoreUpdates: $testScoreUpdates, '; + + expect(round.toString(), equals(expectedString)); + }); + + test('toString() with null kamikazePlayerIndex', () { + final roundWithoutKamikaze = Round( + roundNum: testRoundNum, + caboPlayerIndex: testCaboPlayerIndex, + kamikazePlayerIndex: null, + scores: testScores, + scoreUpdates: testScoreUpdates, + ); + + final expectedString = 'Round $testRoundNum, ' + 'caboPlayerIndex: $testCaboPlayerIndex, ' + 'kamikazePlayerIndex: null, ' + 'scores: $testScores, ' + 'scoreUpdates: $testScoreUpdates, '; + + expect(roundWithoutKamikaze.toString(), expectedString); + }); + }); +}