..
This commit is contained in:
parent
e992a5ca5e
commit
dde81cab65
@ -1,31 +0,0 @@
|
||||
Extension Discovery Cache
|
||||
=========================
|
||||
|
||||
This folder is used by `package:extension_discovery` to cache lists of
|
||||
packages that contains extensions for other packages.
|
||||
|
||||
DO NOT USE THIS FOLDER
|
||||
----------------------
|
||||
|
||||
* Do not read (or rely) the contents of this folder.
|
||||
* Do write to this folder.
|
||||
|
||||
If you're interested in the lists of extensions stored in this folder use the
|
||||
API offered by package `extension_discovery` to get this information.
|
||||
|
||||
If this package doesn't work for your use-case, then don't try to read the
|
||||
contents of this folder. It may change, and will not remain stable.
|
||||
|
||||
Use package `extension_discovery`
|
||||
---------------------------------
|
||||
|
||||
If you want to access information from this folder.
|
||||
|
||||
Feel free to delete this folder
|
||||
-------------------------------
|
||||
|
||||
Files in this folder act as a cache, and the cache is discarded if the files
|
||||
are older than the modification time of `.dart_tool/package_config.json`.
|
||||
|
||||
Hence, it should never be necessary to clear this cache manually, if you find a
|
||||
need to do please file a bug.
|
||||
@ -1 +0,0 @@
|
||||
{"version":2,"entries":[{"package":"playWith","rootUri":"../","packageUri":"lib/"}]}
|
||||
@ -7,16 +7,20 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.playwith.playwith_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
compileSdk = 34
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
// [수정할 부분]
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
// 기존 1.8 -> 17로 변경
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// [수정할 부분]
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
// 기존 '1.8' -> '17'로 변경
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@ -24,8 +28,11 @@ android {
|
||||
applicationId = "com.playwith.playwith_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// [수정] 최소 SDK는 21 이상이면 됨
|
||||
minSdk = flutter.minSdkVersion
|
||||
// [수정] 타겟 SDK도 34로 올림
|
||||
targetSdk = 34
|
||||
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
@ -1,4 +1,20 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<application
|
||||
android:label="playwith_app"
|
||||
android:name="${applicationName}"
|
||||
@ -30,6 +46,12 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-9504446465764716~3452126047"
|
||||
/>
|
||||
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@ -1,5 +1,43 @@
|
||||
package com.playwith.playwith_app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity: FlutterActivity() {
|
||||
// 멀티캐스트 잠금 객체
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
acquireMulticastLock()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
releaseMulticastLock()
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
try {
|
||||
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
// "multicastLock" 태그로 잠금 생성
|
||||
multicastLock = wifiManager.createMulticastLock("multicastLock")
|
||||
multicastLock?.setReferenceCounted(true)
|
||||
multicastLock?.acquire() // 잠금 활성화 (멀티캐스트 수신 허용)
|
||||
println("[Android Native] Multicast Lock Acquired!")
|
||||
} catch (e: Exception) {
|
||||
println("[Android Native] Failed to acquire Multicast Lock: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseMulticastLock() {
|
||||
try {
|
||||
multicastLock?.release() // 잠금 해제
|
||||
println("[Android Native] Multicast Lock Released!")
|
||||
} catch (e: Exception) {
|
||||
println("[Android Native] Failed to release Multicast Lock: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,20 +5,20 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
// [핵심] 빌드 결과물 위치를 Flutter 표준 경로(../../build)로 변경
|
||||
// 이 코드가 없으면 Flutter가 APK를 찾지 못해 에러가 납니다.
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
}
|
||||
213
apps/app/ios/Podfile.lock
Normal file
213
apps/app/ios/Podfile.lock
Normal file
@ -0,0 +1,213 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
- DKImagePickerController/ImageDataManager (4.3.9)
|
||||
- DKImagePickerController/PhotoGallery (4.3.9):
|
||||
- DKImagePickerController/Core
|
||||
- DKPhotoGallery
|
||||
- DKImagePickerController/Resource (4.3.9)
|
||||
- DKPhotoGallery (0.0.19):
|
||||
- DKPhotoGallery/Core (= 0.0.19)
|
||||
- DKPhotoGallery/Model (= 0.0.19)
|
||||
- DKPhotoGallery/Preview (= 0.0.19)
|
||||
- DKPhotoGallery/Resource (= 0.0.19)
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Core (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Preview
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Model (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Preview (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Resource
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Resource (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- GoogleDataTransport (9.4.1):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleMLKit/BarcodeScanning (6.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 5.0.0)
|
||||
- GoogleMLKit/MLKitCore (6.0.0):
|
||||
- MLKitCommon (~> 11.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (7.13.3):
|
||||
- GoogleUtilities/Privacy
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.13.3):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (7.13.3)
|
||||
- GoogleUtilities/UserDefaults (7.13.3):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilitiesComponents (1.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta5)
|
||||
- MLKitBarcodeScanning (5.0.0):
|
||||
- MLKitCommon (~> 11.0)
|
||||
- MLKitVision (~> 7.0)
|
||||
- MLKitCommon (11.0.0):
|
||||
- GoogleDataTransport (< 10.0, >= 9.4.1)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0)
|
||||
- GoogleUtilitiesComponents (~> 1.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (7.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta5)
|
||||
- MLKitCommon (~> 11.0)
|
||||
- mobile_scanner (5.2.3):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 6.0.0)
|
||||
- nanopb (2.30910.0):
|
||||
- nanopb/decode (= 2.30910.0)
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- SDWebImage (5.21.1):
|
||||
- SDWebImage/Core (= 5.21.1)
|
||||
- SDWebImage/Core (5.21.1)
|
||||
- sqlite3 (3.50.4):
|
||||
- sqlite3/common (= 3.50.4)
|
||||
- sqlite3/common (3.50.4)
|
||||
- sqlite3/dbstatvtab (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/math (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3/session (3.50.4):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (~> 3.50.4)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/math
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- sqlite3/session
|
||||
- SwiftyGif (5.4.5)
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GoogleUtilitiesComponents
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- SDWebImage
|
||||
- sqlite3
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
sqlite3_flutter_libs:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4027b33a8f471d996c13f71cb77f0b1583b5d923
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
||||
MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b
|
||||
MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1
|
||||
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
|
||||
mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b
|
||||
sqlite3_flutter_libs: 86f82662868ee26ff3451f73cac9c5fc2a1f57fa
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@ -10,6 +10,8 @@
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
51DBF7C9342CCC1E84264FAF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */; };
|
||||
576F22A04C2FA735A59CCB8C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */; };
|
||||
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 */; };
|
||||
@ -44,10 +46,16 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
34B441CD4D8D7C51D92293E3 /* 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 = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
4E75157E0476F57C9A36AAFF /* 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 = "<group>"; };
|
||||
57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
7CAA68E8ECB59955A38AF698 /* 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 = "<group>"; };
|
||||
7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@ -55,13 +63,24 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D4D50D0F4B952368E1319826 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F6B684F378DF7FE6F2C3CABC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
373154A272D279F7BAD56A3D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
51DBF7C9342CCC1E84264FAF /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
576F22A04C2FA735A59CCB8C /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -76,6 +95,15 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86E8FF9E84BDE142E9B2A7E5 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8D742755804ABA6F6EFA09B0 /* Pods_Runner.framework */,
|
||||
57CE06CA7580196D1806764B /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -94,6 +122,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
A70DA976E0448863AE3AAB92 /* Pods */,
|
||||
86E8FF9E84BDE142E9B2A7E5 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -121,6 +151,20 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A70DA976E0448863AE3AAB92 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
34B441CD4D8D7C51D92293E3 /* Pods-Runner.debug.xcconfig */,
|
||||
F6B684F378DF7FE6F2C3CABC /* Pods-Runner.release.xcconfig */,
|
||||
D4D50D0F4B952368E1319826 /* Pods-Runner.profile.xcconfig */,
|
||||
7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */,
|
||||
4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */,
|
||||
7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@ -128,8 +172,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
72139F7C55C8F7C501C7B164 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
373154A272D279F7BAD56A3D /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -145,12 +191,15 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
88859AD75AFEC32170AD0FCA /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
9DCF60722691A8C657DDBEC4 /* [CP] Embed Pods Frameworks */,
|
||||
AB0501777866B4369253924C /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -238,6 +287,50 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
72139F7C55C8F7C501C7B164 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
88859AD75AFEC32170AD0FCA /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@ -253,6 +346,40 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
9DCF60722691A8C657DDBEC4 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
AB0501777866B4369253924C /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@ -379,6 +506,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7DB0FB06F75F1BDF46E6C95B /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@ -396,6 +524,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 4E75157E0476F57C9A36AAFF /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@ -411,6 +540,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7CAA68E8ECB59955A38AF698 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@ -45,5 +45,23 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>주변 친구들과 게임을 하기 위해 로컬 네트워크 권한이 필요합니다.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_playwith._tcp</string>
|
||||
</array>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-3940256099942544~1458002511</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>QR 코드를 스캔하여 방에 접속하기 위해 카메라 권한이 필요합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>채팅방에 사진을 공유하기 위해 갤러리 접근 권한이 필요합니다.</string>
|
||||
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>사진을 찍어 공유하기 위해 카메라 권한이 필요합니다.</string>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>동영상 촬영을 위해 마이크 권한이 필요합니다.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'dart:io'; // Platform 확인용
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart'; // Core 패키지 import
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import 'lobby_screen.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
@ -12,13 +14,47 @@ class IntroScreen extends StatefulWidget {
|
||||
class _IntroScreenState extends State<IntroScreen> {
|
||||
final _nicknameController = TextEditingController();
|
||||
|
||||
void _enterLobby() {
|
||||
if (_nicknameController.text.trim().isEmpty) return;
|
||||
Future<void> _enterLobby() async {
|
||||
if (_nicknameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("닉네임을 입력해주세요.")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Core 패키지의 NetworkManager 초기화
|
||||
// [수정됨] 플랫폼별 권한 분기 처리
|
||||
if (Platform.isAndroid) {
|
||||
// 🤖 안드로이드: 명시적 권한 요청 필요
|
||||
Map<Permission, PermissionStatus> statuses = await [
|
||||
Permission.location, // 안드로이드 12 이하
|
||||
Permission.nearbyWifiDevices, // 안드로이드 13 이상
|
||||
].request();
|
||||
|
||||
// 로그 확인용
|
||||
bool isNearby = statuses[Permission.nearbyWifiDevices]?.isGranted ?? false;
|
||||
bool isLocation = statuses[Permission.location]?.isGranted ?? false;
|
||||
print("Android 권한 Check: Nearby=$isNearby, Location=$isLocation");
|
||||
|
||||
// 둘 다 거부되면 진행 불가 (단, 버전에 따라 하나만 있어도 됨)
|
||||
// 여기서는 "둘 다 false일 때만" 막는 것으로 완화
|
||||
if (!isNearby && !isLocation) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("❌ 안드로이드는 권한이 필요합니다.")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// 🍎 iOS: 별도 요청 불필요
|
||||
// Info.plist에 설정만 잘 되어 있다면,
|
||||
// NetworkManager가 start() 될 때 시스템이 알아서 물어봅니다.
|
||||
print("iOS는 권한 체크를 건너뜁니다. (실행 시 자동 팝업됨)");
|
||||
}
|
||||
|
||||
// 초기화 및 입장
|
||||
NetworkManager().initialize(nickname: _nicknameController.text.trim());
|
||||
|
||||
// 2. 로비 화면으로 이동
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LobbyScreen()),
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:bonsoir/bonsoir.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:playwith_core/playwith_core.dart'; // GameChatOverlay 포함됨
|
||||
import 'package:playwith_game_quiz/quiz_game.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
class LobbyScreen extends StatefulWidget {
|
||||
const LobbyScreen({super.key});
|
||||
@ -10,174 +14,470 @@ class LobbyScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LobbyScreenState extends State<LobbyScreen> {
|
||||
final _net = NetworkManager(); // Singleton 인스턴스
|
||||
final _net = NetworkManager();
|
||||
final List<String> _logs = [];
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_net.logStream.listen((log) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_logs.add(log);
|
||||
if (_logs.length > 100) _logs.removeAt(0);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_net.messageStream.listen((data) {
|
||||
if (data['type'] == 'GAME_START') {
|
||||
final String gameId = data['gameId'];
|
||||
if (gameId == 'quiz_ox') {
|
||||
_startGameAndNavigate(QuizGame());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// [핵심] 채팅 오버레이 적용
|
||||
void _startGameAndNavigate(BaseGame game) {
|
||||
if (!mounted) return;
|
||||
|
||||
game.onStart();
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) {
|
||||
Widget gameView;
|
||||
if (_net.role == NetworkRole.host) {
|
||||
gameView = game.buildHostView(context);
|
||||
} else {
|
||||
gameView = game.buildGuestView(context);
|
||||
}
|
||||
|
||||
// 플랫폼 구조: 게임 화면 위에 채팅창 오버레이
|
||||
return Stack(
|
||||
children: [
|
||||
gameView, // 1. 게임 화면
|
||||
const SafeArea(
|
||||
child: GameChatOverlay(), // 2. 채팅창 (Core 제공)
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// NetworkManager의 상태(notifyListeners)가 변경될 때마다 화면 다시 그림
|
||||
return ListenableBuilder(
|
||||
listenable: _net,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('안녕하세요, ${_net.me.nickname}님'),
|
||||
title: Text('대기실: ${_net.me.nickname}'),
|
||||
actions: [
|
||||
if (_net.role == NetworkRole.host)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, size: 30),
|
||||
onPressed: () => _showHostQRDialog(),
|
||||
),
|
||||
|
||||
if (_net.role != NetworkRole.none)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _net.stopNetwork(), // 연결 끊기
|
||||
icon: const Icon(Icons.exit_to_app),
|
||||
onPressed: () => _net.stopNetwork(),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(flex: 3, child: _buildBody()),
|
||||
const Divider(thickness: 2, color: Colors.grey),
|
||||
_buildDebugConsole(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
// 1. 아무 역할도 없을 때 -> 선택 화면
|
||||
if (_net.role == NetworkRole.none) {
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_BigButton(
|
||||
title: "방 만들기\n(Host)",
|
||||
color: Colors.blue[100]!,
|
||||
onTap: () => _net.startHosting("${_net.me.nickname}의 방"),
|
||||
),
|
||||
_BigButton(
|
||||
title: "방 찾기\n(Guest)",
|
||||
color: Colors.green[100]!,
|
||||
onTap: () => _showRoomListDialog(),
|
||||
),
|
||||
],
|
||||
Widget _buildDebugConsole() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
color: Colors.black87,
|
||||
child: const Text("DEBUG LOGS", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Host 상태일 때 -> 대기실 화면
|
||||
if (_net.role == NetworkRole.host) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("👑 방장입니다", style: TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 20),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
const Text("참가자를 기다리는 중..."),
|
||||
// TODO: 여기에 접속한 게스트 목록 표시 예정
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Guest 상태일 때 -> 대기실 화면
|
||||
if (_net.role == NetworkRole.guest) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("✅ 접속 완료!", style: TextStyle(fontSize: 24)),
|
||||
SizedBox(height: 20),
|
||||
Text("방장이 게임을 시작하기를 기다리세요."),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
// [Guest용] 방 목록 팝업
|
||||
void _showRoomListDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text("방 찾는 중..."),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: StreamBuilder<List<BonsoirService>>(
|
||||
stream: _net.discoverRooms(), // Core의 방 찾기 스트림
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return const Center(child: Text("발견된 방이 없습니다.\n(같은 와이파이인지 확인하세요)"));
|
||||
}
|
||||
|
||||
final services = snapshot.data!;
|
||||
return ListView.builder(
|
||||
itemCount: services.length,
|
||||
itemBuilder: (context, index) {
|
||||
final service = services[index];
|
||||
// 이름 포맷: "방이름#ID" -> "방이름"만 파싱
|
||||
final displayName = service.name.split('#').first;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.meeting_room),
|
||||
title: Text(displayName),
|
||||
subtitle: Text(service.host ?? "IP 정보 없음"),
|
||||
onTap: () async {
|
||||
Navigator.pop(context); // 다이얼로그 닫기
|
||||
// 해당 방으로 접속 시도 (IP는 service.attributes나 resolve 과정 필요)
|
||||
// Bonsoir는 service.host에 호스트네임이 들어오므로 resolve 필요
|
||||
// MVP 단계에서는 간단히:
|
||||
await _resolveAndJoin(service);
|
||||
},
|
||||
);
|
||||
},
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
|
||||
child: Text(
|
||||
_logs[index],
|
||||
style: const TextStyle(color: Colors.greenAccent, fontSize: 12, fontFamily: 'Courier'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Bonsoir Service Resolve (IP 주소 알아내기)
|
||||
Future<void> _resolveAndJoin(BonsoirService service) async {
|
||||
// 실제로는 service.resolve() 호출 후 IP 획득 과정을 거쳐야 함.
|
||||
// Bonsoir 패키지 특성상 resolve가 비동기로 돔.
|
||||
// MVP 간소화를 위해 service에 host 정보가 있다고 가정하거나
|
||||
// Broadcast 시점에 attributes에 IP를 넣는 방식을 추천하지만,
|
||||
// 일단 resolve 시도:
|
||||
if (service is BonsoirBroadcast) {
|
||||
// 이미 broadcast 객체라면 바로 정보가 있음 (내가 만든 방)
|
||||
} else {
|
||||
await service.resolve(service.resolveRealService);
|
||||
}
|
||||
|
||||
// IP가 ipv4 형태인지 확인 필요. 보통 service.ip 나 attributes 사용
|
||||
// 여기선 port만 확실하므로, 실제 IP 획득은 Bonsoir 예제 참고 필요
|
||||
// (테스트 환경에서는 보통 service.attributes에 {'ip': '192.168...'} 넣어서 보냄)
|
||||
|
||||
// *중요*: 실제 구현 시 NetworkManager.startHosting에서 attributes에 IP를 넣어주는게 가장 확실함.
|
||||
// 일단 현재 코드는 로직 흐름만 잡음.
|
||||
|
||||
// _net.joinRoom('192.168.0.xxx', service.port);
|
||||
Widget _buildBody() {
|
||||
if (_net.role == NetworkRole.none) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_BigButton(
|
||||
title: "방 만들기\n(Host)",
|
||||
color: Colors.blue[100]!,
|
||||
icon: Icons.add_home_work,
|
||||
onTap: () {
|
||||
_net.startHosting("${_net.me.nickname}의 방");
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _net.role == NetworkRole.host) _showHostQRDialog();
|
||||
});
|
||||
},
|
||||
),
|
||||
_BigButton(
|
||||
title: "방 찾기\n(Guest)",
|
||||
color: Colors.green[100]!,
|
||||
icon: Icons.search,
|
||||
onTap: () => _showRoomListDialog(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.qr_code_scanner, size: 28),
|
||||
label: const Text("QR 코드로 접속하기"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
),
|
||||
onPressed: () => _openQRScanner(),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => _showManualJoinDialog(),
|
||||
child: const Text("IP 주소 직접 입력 (비상용)", style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
color: Colors.grey[100],
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(_net.role == NetworkRole.host ? Icons.wifi_tethering : Icons.wifi, color: Colors.blue),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_net.role == NetworkRole.host ? "👑 방장 (나)" : "참가자 (나)",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_net.role == NetworkRole.host) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SelectableText(
|
||||
"IP: ${_net.hostIp} / Port: ${_net.hostPort}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
InkWell(
|
||||
onTap: () => _showHostQRDialog(),
|
||||
child: const Icon(Icons.qr_code, color: Colors.black87),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const Text("QR 버튼을 눌러 친구들을 초대하세요!", style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text("참가자 목록", style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 10),
|
||||
_buildUserTile(_net.me),
|
||||
..._net.guestList.map((guest) => _buildUserTile(guest)),
|
||||
|
||||
if (_net.guestList.isEmpty && _net.role == NetworkRole.host)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(40.0),
|
||||
child: Center(child: Text("참가자를 기다리는 중...\nQR 코드를 보여주세요.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
_buildReadyButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserTile(UserInfo user) {
|
||||
bool isMe = user.id == _net.me.id;
|
||||
return Card(
|
||||
elevation: 2,
|
||||
color: user.isReady ? Colors.green[50] : Colors.white,
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Color(user.colorValue),
|
||||
child: Text(user.nickname[0], style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(
|
||||
user.nickname + (isMe ? " (나)" : ""),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: user.isReady
|
||||
? const Icon(Icons.check_circle, color: Colors.green, size: 32)
|
||||
: const Icon(Icons.hourglass_empty, color: Colors.grey, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadyButton() {
|
||||
bool isReady = _net.me.isReady;
|
||||
bool canReady = _net.role == NetworkRole.host
|
||||
? _net.guestList.isNotEmpty
|
||||
: true;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: canReady
|
||||
? () => _net.toggleReady()
|
||||
: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("참가자가 들어와야 게임을 시작할 수 있습니다.")));
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: !canReady
|
||||
? Colors.grey
|
||||
: (isReady ? Colors.redAccent : Colors.blueAccent),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: canReady ? 5 : 0,
|
||||
),
|
||||
child: Text(
|
||||
isReady ? "준비 취소 (WAIT)" : "준비 완료 (READY)",
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHostQRDialog() {
|
||||
if (_net.hostIp == null || _net.hostPort == null) return;
|
||||
final qrData = jsonEncode({'ip': _net.hostIp, 'port': _net.hostPort});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("초대 QR 코드", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(10)),
|
||||
child: QrImageView(data: qrData, version: QrVersions.auto, size: 220.0),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text("IP: ${_net.hostIp} / Port: ${_net.hostPort}", style: const TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("닫기"))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openQRScanner() {
|
||||
bool isScanCompleted = false;
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text("QR 코드 스캔")),
|
||||
body: MobileScanner(
|
||||
onDetect: (capture) {
|
||||
if (isScanCompleted) return;
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
final String? code = barcode.rawValue;
|
||||
if (code != null) {
|
||||
try {
|
||||
final data = jsonDecode(code);
|
||||
if (data['ip'] != null && data['port'] != null) {
|
||||
isScanCompleted = true;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("QR 인식 성공! 접속 중...")));
|
||||
_net.joinRoom(data['ip'], data['port']);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _showRoomListDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("방 찾는 중..."),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: 300,
|
||||
child: StreamBuilder<List<BonsoirService>>(
|
||||
stream: _net.discoverRooms(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return const Center(child: Text("검색 중... (로그를 확인하세요)"));
|
||||
}
|
||||
final services = snapshot.data!;
|
||||
return ListView.builder(
|
||||
itemCount: services.length,
|
||||
itemBuilder: (context, index) {
|
||||
final service = services[index];
|
||||
final displayName = service.name.split('#').first;
|
||||
final ip = service.attributes?['ip'] ?? '알 수 없음';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.meeting_room),
|
||||
title: Text(displayName),
|
||||
subtitle: Text("IP: $ip"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (service.attributes != null && service.attributes!['ip'] != null) {
|
||||
_net.joinRoom(service.attributes!['ip']!, service.port);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("닫기"))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showManualJoinDialog() {
|
||||
final ipController = TextEditingController(text: "192.168.");
|
||||
final portController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("수동 접속"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("방장 화면의 IP/Port를 입력하세요."),
|
||||
TextField(controller: ipController, decoration: const InputDecoration(labelText: "IP")),
|
||||
TextField(controller: portController, decoration: const InputDecoration(labelText: "Port")),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("취소")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final ip = ipController.text.trim();
|
||||
final port = int.tryParse(portController.text.trim());
|
||||
if (ip.isNotEmpty && port != null) {
|
||||
Navigator.pop(context);
|
||||
_net.joinRoom(ip, port);
|
||||
}
|
||||
},
|
||||
child: const Text("접속"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BigButton extends StatelessWidget {
|
||||
final String title;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _BigButton({required this.title, required this.color, required this.onTap});
|
||||
|
||||
const _BigButton({required this.title, required this.color, required this.icon, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(20)),
|
||||
child: Center(child: Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold))),
|
||||
width: 140, height: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: color, borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, 5))],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 40, color: Colors.black54),
|
||||
const SizedBox(height: 10),
|
||||
Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
import 'package:playwith_game_quiz/quiz_game.dart'; // 퀴즈 모듈 import
|
||||
import 'intro_screen.dart';
|
||||
|
||||
void main() {
|
||||
// 1. 플러터 바인딩 초기화
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 2. 사운드 리소스 주입 (여기가 핵심!)
|
||||
// AssetSource는 'assets/' 접두어를 자동으로 붙이므로 그 하위 경로만 적습니다.
|
||||
SoundManager().initialize(soundPaths: {
|
||||
SoundKey.bgm: 'audio/bgm.mp3',
|
||||
SoundKey.correct: 'audio/correct.mp3',
|
||||
SoundKey.wrong: 'audio/wrong.mp3',
|
||||
SoundKey.win: 'audio/win.mp3',
|
||||
});
|
||||
|
||||
runApp(const PlayWithApp());
|
||||
}
|
||||
|
||||
class PlayWithApp extends StatelessWidget {
|
||||
class PlayWithApp extends StatefulWidget {
|
||||
const PlayWithApp({super.key});
|
||||
|
||||
@override
|
||||
State<PlayWithApp> createState() => _PlayWithAppState();
|
||||
}
|
||||
|
||||
class _PlayWithAppState extends State<PlayWithApp> {
|
||||
final _net = NetworkManager();
|
||||
|
||||
// 등록된 게임 목록
|
||||
final List<BaseGame> _games = [
|
||||
QuizGame(), // 여기서 등록!
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// [전역 라우팅] 네트워크 메시지를 감시하다가 'GAME_START'가 오면 해당 게임 실행
|
||||
_net.messageStream.listen((data) {
|
||||
if (data['type'] == 'GAME_START') {
|
||||
final String gameId = data['gameId'];
|
||||
|
||||
// ID에 맞는 게임 찾기
|
||||
final game = _games.firstWhere(
|
||||
(g) => g.id == gameId,
|
||||
orElse: () => throw Exception("Game not found: $gameId")
|
||||
);
|
||||
|
||||
// 게임 화면으로 이동 (네비게이터 키를 안 쓰고 있어서 간단히 처리 불가, 아래 설명 참조)
|
||||
// 실제로는 GlobalKey<NavigatorState>를 쓰거나, 현재 context를 찾아야 함.
|
||||
// MVP에서는 LobbyScreen 내부에서 처리하는 것이 안전함.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
|
||||
@ -6,6 +6,18 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
sqlite3_flutter_libs
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@ -5,8 +5,16 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audioplayers_darwin
|
||||
import bonsoir_darwin
|
||||
import file_selector_macos
|
||||
import mobile_scanner
|
||||
import sqlite3_flutter_libs
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
}
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -9,38 +17,110 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audioplayers
|
||||
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.1"
|
||||
audioplayers_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_android
|
||||
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
audioplayers_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_darwin
|
||||
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
audioplayers_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_linux
|
||||
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
audioplayers_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_platform_interface
|
||||
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.1"
|
||||
audioplayers_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_web
|
||||
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
audioplayers_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_windows
|
||||
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
bonsoir:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: d5b0cb2d38ac8b0057990e3046ef0e4552b63f6d636284c7e57ac2b257e2000a
|
||||
sha256: "15de9d734708ccce1484ea9bbc750e364af88cb30fe6392368dc0cf233ddee5e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
version: "6.0.1"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: "21c38f707df0755b5bedd05c5fd52cb5e9fa9f4a4efbcef94cb9669029c73d75"
|
||||
sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "6.0.1"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: fe36cb2acec37c175213364a91ae1b2866a47e061f18b11322ac8e0ca39d5e61
|
||||
sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
version: "6.0.1"
|
||||
bonsoir_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_linux
|
||||
sha256: "5f40f8fa6dc79245ddde38f4440bc0f49cd25ee6be04a4e56fe9fca0d2be7998"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
bonsoir_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_platform_interface
|
||||
sha256: "97081d861ff2e7b45edd9e17ae1b35530d5a7ec561e3377e00da2e0cfae500dc"
|
||||
sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "6.0.1"
|
||||
bonsoir_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_windows
|
||||
sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -73,6 +153,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -81,6 +177,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
drift:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: drift
|
||||
sha256: "83290a32ae006a7535c5ecf300722cb77177250d9df4ee2becc5fa8a36095114"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.29.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -97,6 +209,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -118,11 +286,104 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+10"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+2"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -179,6 +440,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -187,6 +472,118 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
playwith_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -209,6 +606,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@ -222,6 +643,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.40"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -246,6 +683,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -294,6 +739,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@ -35,6 +35,11 @@ dependencies:
|
||||
path: ../../packages/core
|
||||
playwith_game_quiz:
|
||||
path: ../../packages/games/quiz
|
||||
permission_handler: ^11.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^5.1.0
|
||||
audioplayers: ^6.0.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -52,39 +57,6 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
assets:
|
||||
- assets/audio/
|
||||
@ -6,6 +6,21 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
BonsoirWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
}
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
bonsoir_windows
|
||||
file_selector_windows
|
||||
permission_handler_windows
|
||||
sqlite3_flutter_libs
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
47
packages/core/lib/database/ephemeral_database.dart
Normal file
47
packages/core/lib/database/ephemeral_database.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
part 'ephemeral_database.g.dart'; // (나중에 생성될 파일)
|
||||
|
||||
// 테이블 정의: 미디어 메타데이터
|
||||
class MediaItems extends Table {
|
||||
TextColumn get id => text()(); // UUID
|
||||
TextColumn get senderId => text()();
|
||||
TextColumn get senderName => text()();
|
||||
TextColumn get type => text()(); // 'IMAGE', 'VIDEO', 'AUDIO'
|
||||
TextColumn get filePath => text()(); // 로컬 저장 경로
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [MediaItems])
|
||||
class EphemeralDatabase extends _$EphemeralDatabase {
|
||||
// 싱글톤이 아닌 인스턴스로 관리 (방 만들 때 생성, 나갈 때 close)
|
||||
EphemeralDatabase(QueryExecutor e) : super(e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
// 팩토리: 파일 기반 DB 생성
|
||||
static Future<EphemeralDatabase> create(String roomName) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
// 방 이름이나 ID로 파일명 구분
|
||||
final file = File(p.join(dbFolder.path, 'room_$roomName.sqlite'));
|
||||
return EphemeralDatabase(NativeDatabase(file));
|
||||
}
|
||||
|
||||
// CRUD 쿼리
|
||||
Future<List<MediaItem>> getAllMedia() => select(mediaItems).get();
|
||||
Future<int> insertMedia(MediaItemsCompanion entry) => into(mediaItems).insert(entry);
|
||||
|
||||
// [핵심] 방 폭파 시 데이터 삭제
|
||||
Future<void> wipeData() async {
|
||||
await close(); // DB 연결 종료
|
||||
// 실제 파일 삭제 로직은 Manager에서 수행 (DB 파일 자체를 날림)
|
||||
}
|
||||
}
|
||||
557
packages/core/lib/database/ephemeral_database.g.dart
Normal file
557
packages/core/lib/database/ephemeral_database.g.dart
Normal file
@ -0,0 +1,557 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ephemeral_database.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
class $MediaItemsTable extends MediaItems
|
||||
with TableInfo<$MediaItemsTable, MediaItem> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$MediaItemsTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||
@override
|
||||
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||
'id', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _senderIdMeta =
|
||||
const VerificationMeta('senderId');
|
||||
@override
|
||||
late final GeneratedColumn<String> senderId = GeneratedColumn<String>(
|
||||
'sender_id', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _senderNameMeta =
|
||||
const VerificationMeta('senderName');
|
||||
@override
|
||||
late final GeneratedColumn<String> senderName = GeneratedColumn<String>(
|
||||
'sender_name', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _typeMeta = const VerificationMeta('type');
|
||||
@override
|
||||
late final GeneratedColumn<String> type = GeneratedColumn<String>(
|
||||
'type', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _filePathMeta =
|
||||
const VerificationMeta('filePath');
|
||||
@override
|
||||
late final GeneratedColumn<String> filePath = GeneratedColumn<String>(
|
||||
'file_path', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _createdAtMeta =
|
||||
const VerificationMeta('createdAt');
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
|
||||
'created_at', aliasedName, false,
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: currentDateAndTime);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[id, senderId, senderName, type, filePath, createdAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'media_items';
|
||||
@override
|
||||
VerificationContext validateIntegrity(Insertable<MediaItem> instance,
|
||||
{bool isInserting = false}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('id')) {
|
||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_idMeta);
|
||||
}
|
||||
if (data.containsKey('sender_id')) {
|
||||
context.handle(_senderIdMeta,
|
||||
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_senderIdMeta);
|
||||
}
|
||||
if (data.containsKey('sender_name')) {
|
||||
context.handle(
|
||||
_senderNameMeta,
|
||||
senderName.isAcceptableOrUnknown(
|
||||
data['sender_name']!, _senderNameMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_senderNameMeta);
|
||||
}
|
||||
if (data.containsKey('type')) {
|
||||
context.handle(
|
||||
_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_typeMeta);
|
||||
}
|
||||
if (data.containsKey('file_path')) {
|
||||
context.handle(_filePathMeta,
|
||||
filePath.isAcceptableOrUnknown(data['file_path']!, _filePathMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_filePathMeta);
|
||||
}
|
||||
if (data.containsKey('created_at')) {
|
||||
context.handle(_createdAtMeta,
|
||||
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
MediaItem map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return MediaItem(
|
||||
id: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}id'])!,
|
||||
senderId: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}sender_id'])!,
|
||||
senderName: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}sender_name'])!,
|
||||
type: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}type'])!,
|
||||
filePath: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}file_path'])!,
|
||||
createdAt: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$MediaItemsTable createAlias(String alias) {
|
||||
return $MediaItemsTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaItem extends DataClass implements Insertable<MediaItem> {
|
||||
final String id;
|
||||
final String senderId;
|
||||
final String senderName;
|
||||
final String type;
|
||||
final String filePath;
|
||||
final DateTime createdAt;
|
||||
const MediaItem(
|
||||
{required this.id,
|
||||
required this.senderId,
|
||||
required this.senderName,
|
||||
required this.type,
|
||||
required this.filePath,
|
||||
required this.createdAt});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<String>(id);
|
||||
map['sender_id'] = Variable<String>(senderId);
|
||||
map['sender_name'] = Variable<String>(senderName);
|
||||
map['type'] = Variable<String>(type);
|
||||
map['file_path'] = Variable<String>(filePath);
|
||||
map['created_at'] = Variable<DateTime>(createdAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
MediaItemsCompanion toCompanion(bool nullToAbsent) {
|
||||
return MediaItemsCompanion(
|
||||
id: Value(id),
|
||||
senderId: Value(senderId),
|
||||
senderName: Value(senderName),
|
||||
type: Value(type),
|
||||
filePath: Value(filePath),
|
||||
createdAt: Value(createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
factory MediaItem.fromJson(Map<String, dynamic> json,
|
||||
{ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return MediaItem(
|
||||
id: serializer.fromJson<String>(json['id']),
|
||||
senderId: serializer.fromJson<String>(json['senderId']),
|
||||
senderName: serializer.fromJson<String>(json['senderName']),
|
||||
type: serializer.fromJson<String>(json['type']),
|
||||
filePath: serializer.fromJson<String>(json['filePath']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<String>(id),
|
||||
'senderId': serializer.toJson<String>(senderId),
|
||||
'senderName': serializer.toJson<String>(senderName),
|
||||
'type': serializer.toJson<String>(type),
|
||||
'filePath': serializer.toJson<String>(filePath),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
MediaItem copyWith(
|
||||
{String? id,
|
||||
String? senderId,
|
||||
String? senderName,
|
||||
String? type,
|
||||
String? filePath,
|
||||
DateTime? createdAt}) =>
|
||||
MediaItem(
|
||||
id: id ?? this.id,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
type: type ?? this.type,
|
||||
filePath: filePath ?? this.filePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
MediaItem copyWithCompanion(MediaItemsCompanion data) {
|
||||
return MediaItem(
|
||||
id: data.id.present ? data.id.value : this.id,
|
||||
senderId: data.senderId.present ? data.senderId.value : this.senderId,
|
||||
senderName:
|
||||
data.senderName.present ? data.senderName.value : this.senderName,
|
||||
type: data.type.present ? data.type.value : this.type,
|
||||
filePath: data.filePath.present ? data.filePath.value : this.filePath,
|
||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MediaItem(')
|
||||
..write('id: $id, ')
|
||||
..write('senderId: $senderId, ')
|
||||
..write('senderName: $senderName, ')
|
||||
..write('type: $type, ')
|
||||
..write('filePath: $filePath, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, senderId, senderName, type, filePath, createdAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is MediaItem &&
|
||||
other.id == this.id &&
|
||||
other.senderId == this.senderId &&
|
||||
other.senderName == this.senderName &&
|
||||
other.type == this.type &&
|
||||
other.filePath == this.filePath &&
|
||||
other.createdAt == this.createdAt);
|
||||
}
|
||||
|
||||
class MediaItemsCompanion extends UpdateCompanion<MediaItem> {
|
||||
final Value<String> id;
|
||||
final Value<String> senderId;
|
||||
final Value<String> senderName;
|
||||
final Value<String> type;
|
||||
final Value<String> filePath;
|
||||
final Value<DateTime> createdAt;
|
||||
final Value<int> rowid;
|
||||
const MediaItemsCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.senderId = const Value.absent(),
|
||||
this.senderName = const Value.absent(),
|
||||
this.type = const Value.absent(),
|
||||
this.filePath = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
MediaItemsCompanion.insert({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
this.createdAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
senderId = Value(senderId),
|
||||
senderName = Value(senderName),
|
||||
type = Value(type),
|
||||
filePath = Value(filePath);
|
||||
static Insertable<MediaItem> custom({
|
||||
Expression<String>? id,
|
||||
Expression<String>? senderId,
|
||||
Expression<String>? senderName,
|
||||
Expression<String>? type,
|
||||
Expression<String>? filePath,
|
||||
Expression<DateTime>? createdAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (senderId != null) 'sender_id': senderId,
|
||||
if (senderName != null) 'sender_name': senderName,
|
||||
if (type != null) 'type': type,
|
||||
if (filePath != null) 'file_path': filePath,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
MediaItemsCompanion copyWith(
|
||||
{Value<String>? id,
|
||||
Value<String>? senderId,
|
||||
Value<String>? senderName,
|
||||
Value<String>? type,
|
||||
Value<String>? filePath,
|
||||
Value<DateTime>? createdAt,
|
||||
Value<int>? rowid}) {
|
||||
return MediaItemsCompanion(
|
||||
id: id ?? this.id,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
type: type ?? this.type,
|
||||
filePath: filePath ?? this.filePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<String>(id.value);
|
||||
}
|
||||
if (senderId.present) {
|
||||
map['sender_id'] = Variable<String>(senderId.value);
|
||||
}
|
||||
if (senderName.present) {
|
||||
map['sender_name'] = Variable<String>(senderName.value);
|
||||
}
|
||||
if (type.present) {
|
||||
map['type'] = Variable<String>(type.value);
|
||||
}
|
||||
if (filePath.present) {
|
||||
map['file_path'] = Variable<String>(filePath.value);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<DateTime>(createdAt.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MediaItemsCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('senderId: $senderId, ')
|
||||
..write('senderName: $senderName, ')
|
||||
..write('type: $type, ')
|
||||
..write('filePath: $filePath, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$EphemeralDatabase extends GeneratedDatabase {
|
||||
_$EphemeralDatabase(QueryExecutor e) : super(e);
|
||||
$EphemeralDatabaseManager get managers => $EphemeralDatabaseManager(this);
|
||||
late final $MediaItemsTable mediaItems = $MediaItemsTable(this);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems];
|
||||
}
|
||||
|
||||
typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
Value<DateTime> createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$MediaItemsTableUpdateCompanionBuilder = MediaItemsCompanion Function({
|
||||
Value<String> id,
|
||||
Value<String> senderId,
|
||||
Value<String> senderName,
|
||||
Value<String> type,
|
||||
Value<String> filePath,
|
||||
Value<DateTime> createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$MediaItemsTableFilterComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get id => $composableBuilder(
|
||||
column: $table.id, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get senderId => $composableBuilder(
|
||||
column: $table.senderId, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get type => $composableBuilder(
|
||||
column: $table.type, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get filePath => $composableBuilder(
|
||||
column: $table.filePath, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt, builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$MediaItemsTableOrderingComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get id => $composableBuilder(
|
||||
column: $table.id, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get senderId => $composableBuilder(
|
||||
column: $table.senderId, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get type => $composableBuilder(
|
||||
column: $table.type, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get filePath => $composableBuilder(
|
||||
column: $table.filePath, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$MediaItemsTableAnnotationComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get senderId =>
|
||||
$composableBuilder(column: $table.senderId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get type =>
|
||||
$composableBuilder(column: $table.type, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get filePath =>
|
||||
$composableBuilder(column: $table.filePath, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get createdAt =>
|
||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$MediaItemsTableTableManager extends RootTableManager<
|
||||
_$EphemeralDatabase,
|
||||
$MediaItemsTable,
|
||||
MediaItem,
|
||||
$$MediaItemsTableFilterComposer,
|
||||
$$MediaItemsTableOrderingComposer,
|
||||
$$MediaItemsTableAnnotationComposer,
|
||||
$$MediaItemsTableCreateCompanionBuilder,
|
||||
$$MediaItemsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MediaItem,
|
||||
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
|
||||
),
|
||||
MediaItem,
|
||||
PrefetchHooks Function()> {
|
||||
$$MediaItemsTableTableManager(_$EphemeralDatabase db, $MediaItemsTable table)
|
||||
: super(TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$MediaItemsTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$MediaItemsTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$MediaItemsTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback: ({
|
||||
Value<String> id = const Value.absent(),
|
||||
Value<String> senderId = const Value.absent(),
|
||||
Value<String> senderName = const Value.absent(),
|
||||
Value<String> type = const Value.absent(),
|
||||
Value<String> filePath = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
MediaItemsCompanion(
|
||||
id: id,
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
MediaItemsCompanion.insert(
|
||||
id: id,
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
));
|
||||
}
|
||||
|
||||
typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager<
|
||||
_$EphemeralDatabase,
|
||||
$MediaItemsTable,
|
||||
MediaItem,
|
||||
$$MediaItemsTableFilterComposer,
|
||||
$$MediaItemsTableOrderingComposer,
|
||||
$$MediaItemsTableAnnotationComposer,
|
||||
$$MediaItemsTableCreateCompanionBuilder,
|
||||
$$MediaItemsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MediaItem,
|
||||
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
|
||||
),
|
||||
MediaItem,
|
||||
PrefetchHooks Function()>;
|
||||
|
||||
class $EphemeralDatabaseManager {
|
||||
final _$EphemeralDatabase _db;
|
||||
$EphemeralDatabaseManager(this._db);
|
||||
$$MediaItemsTableTableManager get mediaItems =>
|
||||
$$MediaItemsTableTableManager(_db, _db.mediaItems);
|
||||
}
|
||||
@ -1,31 +1,53 @@
|
||||
// lib/game/base_game.dart
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/play_packet.dart';
|
||||
|
||||
/// 모든 미니게임의 부모 클래스
|
||||
/// 모든 게임의 부모 클래스 (자동 패킷 리스닝 기능 탑재)
|
||||
abstract class BaseGame {
|
||||
/// 게임 고유 ID (예: 'quiz', 'bomb') - 패킷 라우팅용
|
||||
String get id;
|
||||
|
||||
/// 게임 이름 (로비 표시용)
|
||||
String get name;
|
||||
|
||||
/// 게임 설명
|
||||
String get description;
|
||||
|
||||
/// [Host] 방장이 보는 게임 화면 빌드
|
||||
Widget buildHostView(BuildContext context);
|
||||
// 네트워크 구독 관리자 (private)
|
||||
StreamSubscription? _internalSubscription;
|
||||
|
||||
/// [Guest] 참가자가 보는 게임 화면 빌드
|
||||
Widget buildGuestView(BuildContext context);
|
||||
/// [Core] 게임 시작 시 자동 호출 (super.onStart() 필수 호출)
|
||||
@mustCallSuper
|
||||
void onStart() {
|
||||
print("[$name] Game Engine Started");
|
||||
|
||||
// 네트워크 스트림 자동 구독
|
||||
_internalSubscription = NetworkManager().messageStream.listen((payload) {
|
||||
// 1. PlayPacket으로 변환 시도 (구조화된 데이터)
|
||||
if (payload.containsKey('type') && payload.containsKey('payload')) {
|
||||
try {
|
||||
// 만약 패킷 타입이 'game'이라면 onGamePacketReceived 호출
|
||||
// (여기서는 단순화를 위해 모든 JSON을 자식에게 넘기되, 자식이 알아서 필터링하게 하거나
|
||||
// PlayPacket 구조를 강제할 수 있습니다. 현재는 하위 호환성을 위해 raw data 전달)
|
||||
onMessageReceived("", payload);
|
||||
} catch (e) {
|
||||
print("Packet Error: $e");
|
||||
}
|
||||
} else {
|
||||
// 레거시 데이터 처리
|
||||
onMessageReceived("", payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
|
||||
void onStart();
|
||||
/// [Core] 게임 종료 시 자동 호출 (super.onDispose() 필수 호출)
|
||||
@mustCallSuper
|
||||
void onDispose() {
|
||||
print("[$name] Game Engine Disposed");
|
||||
_internalSubscription?.cancel();
|
||||
}
|
||||
|
||||
/// 네트워크 메시지 수신 처리
|
||||
/// [senderId]: 보낸 사람 ID
|
||||
/// [payload]: 수신된 데이터 (JSON)
|
||||
/// [Abstract] 자식 클래스가 구현해야 할 데이터 처리 메서드
|
||||
/// Core가 메시지를 받으면 이 함수를 실행시켜 줍니다.
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload);
|
||||
|
||||
/// 게임 종료 및 메모리 정리
|
||||
void onDispose();
|
||||
// UI 빌더
|
||||
Widget buildHostView(BuildContext context);
|
||||
Widget buildGuestView(BuildContext context);
|
||||
}
|
||||
70
packages/core/lib/manager/global_chat_manager.dart
Normal file
70
packages/core/lib/manager/global_chat_manager.dart
Normal file
@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/play_packet.dart';
|
||||
|
||||
class ChatMessage {
|
||||
final String senderName;
|
||||
final String text;
|
||||
final bool isMe;
|
||||
final DateTime timestamp;
|
||||
|
||||
ChatMessage(this.senderName, this.text, this.isMe) : timestamp = DateTime.now();
|
||||
}
|
||||
|
||||
class GlobalChatManager {
|
||||
static final GlobalChatManager _instance = GlobalChatManager._internal();
|
||||
factory GlobalChatManager() => _instance;
|
||||
GlobalChatManager._internal();
|
||||
|
||||
// UI가 구독할 스트림
|
||||
final _messageController = StreamController<List<ChatMessage>>.broadcast();
|
||||
Stream<List<ChatMessage>> get messageStream => _messageController.stream;
|
||||
|
||||
final List<ChatMessage> _messages = [];
|
||||
|
||||
/// [NetworkManager]로부터 패킷을 전달받음
|
||||
void onPacketReceived(PlayPacket packet) {
|
||||
if (packet.type != PacketType.chat) return;
|
||||
|
||||
final data = packet.payload as Map<String, dynamic>;
|
||||
final senderName = data['senderName'];
|
||||
final text = data['text'];
|
||||
final isMe = packet.senderId == NetworkManager().me.id;
|
||||
|
||||
final chatMsg = ChatMessage(senderName, text, isMe);
|
||||
_messages.add(chatMsg);
|
||||
|
||||
// UI 갱신
|
||||
_messageController.add(List.from(_messages));
|
||||
}
|
||||
|
||||
/// 메시지 전송
|
||||
void sendMessage(String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
|
||||
final myInfo = NetworkManager().me;
|
||||
|
||||
// 1. 내 화면에 즉시 추가 (나한테는 네트워크로 안 돌아오므로)
|
||||
final myMsg = ChatMessage(myInfo.nickname, text, true);
|
||||
_messages.add(myMsg);
|
||||
_messageController.add(List.from(_messages));
|
||||
|
||||
// 2. 네트워크 전송 (PlayPacket 포장)
|
||||
final packet = PlayPacket(
|
||||
type: PacketType.chat,
|
||||
senderId: myInfo.id,
|
||||
payload: {
|
||||
'senderName': myInfo.nickname,
|
||||
'text': text,
|
||||
},
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
NetworkManager().sendPacket(packet);
|
||||
}
|
||||
|
||||
void clearMessages() {
|
||||
_messages.clear();
|
||||
_messageController.add([]);
|
||||
}
|
||||
}
|
||||
149
packages/core/lib/manager/media_manager.dart
Normal file
149
packages/core/lib/manager/media_manager.dart
Normal file
@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/ephemeral_database.dart';
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/play_packet.dart';
|
||||
|
||||
class MediaManager {
|
||||
static final MediaManager _instance = MediaManager._internal();
|
||||
factory MediaManager() => _instance;
|
||||
MediaManager._internal();
|
||||
|
||||
EphemeralDatabase? _db;
|
||||
String? _currentRoomId;
|
||||
|
||||
// UI에서 갤러리 변경을 감지하기 위한 스트림 (DB 변경 시 자동 발동)
|
||||
Stream<List<MediaItem>> get galleryStream {
|
||||
if (_db == null) return const Stream.empty();
|
||||
return _db!.select(_db!.mediaItems).watch(); // watch()는 데이터 변경 시 자동 업데이트됨
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [1] 초기화 및 정리 (Lifecycle)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 방 생성/입장 시 호출 (DB 생성)
|
||||
Future<void> initialize(String roomId) async {
|
||||
// 기존 DB가 열려있다면 정리
|
||||
await cleanup();
|
||||
|
||||
_currentRoomId = roomId;
|
||||
_db = await EphemeralDatabase.create(roomId);
|
||||
print("[MediaManager] DB Initialized for Room: $roomId");
|
||||
}
|
||||
|
||||
/// 방 나갈 때 호출 (데이터 파괴)
|
||||
Future<void> cleanup() async {
|
||||
if (_db != null) {
|
||||
await _db!.close();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
// DB 파일 삭제 (흔적 지우기)
|
||||
if (_currentRoomId != null) {
|
||||
try {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
// EphemeralDatabase.create에서 만든 파일명과 동일해야 함
|
||||
final file = File('${dbFolder.path}/room_$_currentRoomId.sqlite');
|
||||
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
print("[MediaManager] DB File Deleted 🔥");
|
||||
}
|
||||
} catch (e) {
|
||||
print("[MediaManager] Cleanup Error: $e");
|
||||
}
|
||||
_currentRoomId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [2] 미디어 전송 (Send)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> sendMedia({
|
||||
required String filePath,
|
||||
required String type, // 'IMAGE', 'AUDIO'
|
||||
}) async {
|
||||
if (_db == null) return;
|
||||
|
||||
final myInfo = NetworkManager().me;
|
||||
final mediaId = const Uuid().v4();
|
||||
|
||||
// A. 내 로컬 DB에 먼저 저장 (내가 보낸 것도 보여야 하니까)
|
||||
await _db!.insertMedia(MediaItemsCompanion(
|
||||
id: drift.Value(mediaId),
|
||||
senderId: drift.Value(myInfo.id),
|
||||
senderName: drift.Value(myInfo.nickname),
|
||||
type: drift.Value(type),
|
||||
filePath: drift.Value(filePath),
|
||||
createdAt: drift.Value(DateTime.now()),
|
||||
));
|
||||
|
||||
// B. 파일 읽기 및 인코딩 (MVP: Base64)
|
||||
// 주의: 대용량 동영상은 이 방식으로 보내면 앱 멈춤. (추후 Chunk 방식 개선 필요)
|
||||
final file = File(filePath);
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final base64Data = base64Encode(fileBytes);
|
||||
final fileName = filePath.split('/').last;
|
||||
|
||||
// C. 패킷 전송
|
||||
final packet = PlayPacket(
|
||||
type: PacketType.media,
|
||||
senderId: myInfo.id,
|
||||
payload: {
|
||||
'id': mediaId,
|
||||
'senderName': myInfo.nickname,
|
||||
'type': type,
|
||||
'data': base64Data,
|
||||
'fileName': fileName,
|
||||
},
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
NetworkManager().sendPacket(packet);
|
||||
print("[MediaManager] Sent media: $fileName");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [3] 미디어 수신 (Receive)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> onMediaReceived(PlayPacket packet) async {
|
||||
if (_db == null) return;
|
||||
|
||||
try {
|
||||
final data = packet.payload as Map<String, dynamic>;
|
||||
final String base64Data = data['data'];
|
||||
final String fileName = data['fileName'];
|
||||
|
||||
// A. 임시 폴더에 파일 저장
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
// 파일명 충돌 방지를 위해 UUID나 Timestamp 붙여도 됨
|
||||
final savePath = '${tempDir.path}/${const Uuid().v4()}_$fileName';
|
||||
|
||||
final bytes = base64Decode(base64Data);
|
||||
await File(savePath).writeAsBytes(bytes);
|
||||
|
||||
// B. DB에 메타데이터 저장 -> watch() 중인 UI가 자동 업데이트됨
|
||||
await _db!.insertMedia(MediaItemsCompanion(
|
||||
id: drift.Value(data['id']),
|
||||
senderId: drift.Value(packet.senderId),
|
||||
senderName: drift.Value(data['senderName']),
|
||||
type: drift.Value(data['type']),
|
||||
filePath: drift.Value(savePath),
|
||||
createdAt: drift.Value(DateTime.fromMillisecondsSinceEpoch(packet.timestamp)),
|
||||
));
|
||||
|
||||
print("[MediaManager] File Saved: $savePath");
|
||||
|
||||
} catch (e) {
|
||||
print("[MediaManager] Receive Error: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/core/lib/model/play_packet.dart
Normal file
52
packages/core/lib/model/play_packet.dart
Normal file
@ -0,0 +1,52 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 패킷의 종류 (라우팅 기준)
|
||||
enum PacketType {
|
||||
system, // 시스템 (레디, 시작, 종료, 핸드셰이크 등)
|
||||
chat, // 채팅 (GlobalChatManager로 전달)
|
||||
game, // 게임 로직 (GameController로 전달)
|
||||
media,
|
||||
unknown
|
||||
}
|
||||
|
||||
class PlayPacket {
|
||||
final PacketType type;
|
||||
final String senderId; // 보낸 사람 ID
|
||||
final dynamic payload; // 실제 데이터 (Map, List, String 등)
|
||||
final int timestamp;
|
||||
|
||||
PlayPacket({
|
||||
required this.type,
|
||||
required this.senderId,
|
||||
required this.payload,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
// JSON -> 객체
|
||||
factory PlayPacket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayPacket(
|
||||
type: _parseType(json['type']),
|
||||
senderId: json['senderId'] ?? 'unknown',
|
||||
payload: json['payload'],
|
||||
timestamp: json['timestamp'] ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
// 객체 -> JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name, // enum을 문자열로 ('chat', 'game'...)
|
||||
'senderId': senderId,
|
||||
'payload': payload,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
static PacketType _parseType(String? typeStr) {
|
||||
for (var t in PacketType.values) {
|
||||
if (t.name == typeStr) return t;
|
||||
}
|
||||
// 호환성: 기존 레거시 메시지(PING, ANSWER_SUBMIT 등)는 'unknown'이나 별도 처리
|
||||
return PacketType.unknown;
|
||||
}
|
||||
}
|
||||
@ -3,51 +3,54 @@ import 'package:equatable/equatable.dart';
|
||||
class UserInfo extends Equatable {
|
||||
final String id;
|
||||
final String nickname;
|
||||
final int avatarIndex; // 프로필 이미지 대신 사용할 아바타 번호 (0~9 등)
|
||||
final int colorValue; // 유저 고유 컬러 (ARGB int)
|
||||
final int avatarIndex;
|
||||
final int colorValue;
|
||||
final bool isReady; // [추가] 준비 상태
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarIndex = 0,
|
||||
this.colorValue = 0xFF2196F3, // 기본값 Blue
|
||||
this.colorValue = 0xFF2196F3,
|
||||
this.isReady = false, // 기본값 false
|
||||
});
|
||||
|
||||
/// JSON -> Object 변환 (네트워크 수신 시)
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] as String,
|
||||
nickname: json['nickname'] as String,
|
||||
avatarIndex: json['avatarIndex'] as int? ?? 0,
|
||||
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
|
||||
isReady: json['isReady'] as bool? ?? false, // JSON 파싱 추가
|
||||
);
|
||||
}
|
||||
|
||||
/// Object -> JSON 변환 (네트워크 전송 시)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'nickname': nickname,
|
||||
'avatarIndex': avatarIndex,
|
||||
'colorValue': colorValue,
|
||||
'isReady': isReady, // JSON 변환 추가
|
||||
};
|
||||
}
|
||||
|
||||
/// 복사본 생성 (불변 객체 수정용)
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? nickname,
|
||||
int? avatarIndex,
|
||||
int? colorValue,
|
||||
bool? isReady, // copyWith 추가
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
nickname: nickname ?? this.nickname,
|
||||
avatarIndex: avatarIndex ?? this.avatarIndex,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
isReady: isReady ?? this.isReady,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady];
|
||||
}
|
||||
@ -3,121 +3,180 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
|
||||
import 'package:bonsoir/bonsoir.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../model/user_info.dart';
|
||||
import '../model/play_packet.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart'; // [New] 미디어 매니저
|
||||
|
||||
/// 현재 네트워크 상태 (역할)
|
||||
enum NetworkRole { none, host, guest }
|
||||
|
||||
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
|
||||
class NetworkManager extends ChangeNotifier {
|
||||
// ------------------------------------------------------------------------
|
||||
// 1. Singleton & Initialization
|
||||
// ------------------------------------------------------------------------
|
||||
class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static final NetworkManager _instance = NetworkManager._internal();
|
||||
factory NetworkManager() => _instance;
|
||||
NetworkManager._internal();
|
||||
|
||||
/// 내 정보 (앱 시작 시 initialize 호출 필수)
|
||||
late UserInfo me;
|
||||
|
||||
/// 현재 내 역할
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
/// 초기화 메서드 (닉네임 설정 및 ID 생성)
|
||||
void initialize({required String nickname}) {
|
||||
// 8자리 랜덤 ID 생성
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
// 랜덤 컬러 (간단하게 해시코드로 생성 예시)
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
|
||||
me = UserInfo(
|
||||
id: uuid,
|
||||
nickname: nickname,
|
||||
colorValue: randomColor,
|
||||
);
|
||||
print('[Network] Initialized User: ${me.nickname} (${me.id})');
|
||||
NetworkManager._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 2. Variables & Streams
|
||||
// 상태 변수
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// 소켓
|
||||
ServerSocket? _serverSocket; // (Host용)
|
||||
Socket? _clientSocket; // (Guest용)
|
||||
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
|
||||
late UserInfo me;
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
String? hostIp;
|
||||
int? hostPort;
|
||||
|
||||
ServerSocket? _serverSocket;
|
||||
Socket? _clientSocket;
|
||||
|
||||
// 소켓과 유저 정보를 1:1 매핑
|
||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||
|
||||
// UI 표시용 게스트 명단
|
||||
final List<UserInfo> guestList = [];
|
||||
|
||||
// mDNS (방 찾기/만들기)
|
||||
BonsoirService? _bonsoirService;
|
||||
BonsoirBroadcast? _bonsoirBroadcast;
|
||||
BonsoirDiscovery? _bonsoirDiscovery;
|
||||
|
||||
// 수신된 데이터를 앱(GameManager)으로 전달하는 스트림
|
||||
// 게임 데이터 스트림
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 3. Host Logic (방장)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 방 만들기
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
role = NetworkRole.host;
|
||||
// 로그 스트림
|
||||
final _logController = StreamController<String>.broadcast();
|
||||
Stream<String> get logStream => _logController.stream;
|
||||
|
||||
try {
|
||||
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
print('[Host] Server opened on port: $port');
|
||||
// 하트비트 & 재접속
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _disconnectWaitTimer;
|
||||
DateTime? _lastPongTime;
|
||||
bool _isReconnecting = false;
|
||||
|
||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
String myIp = '127.0.0.1';
|
||||
try {
|
||||
// 보통 wlan0 혹은 en0가 와이파이 인터페이스
|
||||
myIp = interfaces.firstWhere((i) => i.name != 'lo').addresses.first.address;
|
||||
} catch (e) {
|
||||
print('IP search failed: $e');
|
||||
static const int HEARTBEAT_INTERVAL_SEC = 3;
|
||||
static const int TIMEOUT_SEC = 10;
|
||||
static const int RECONNECT_WAIT_SEC = 5;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 초기화
|
||||
// ------------------------------------------------------------------------
|
||||
void initialize({required String nickname}) {
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
|
||||
_log("초기화 완료: ${me.nickname}");
|
||||
}
|
||||
|
||||
// B. 게스트 접속 대기
|
||||
void _log(String msg) {
|
||||
final timestamp = DateTime.now().toIso8601String().split('T').last.substring(0, 8);
|
||||
print("[$timestamp] $msg");
|
||||
_logController.add("[$timestamp] $msg");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (role == NetworkRole.guest && _clientSocket == null && hostIp != null) {
|
||||
_attemptReconnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 레디 시스템
|
||||
// ------------------------------------------------------------------------
|
||||
void toggleReady() {
|
||||
me = me.copyWith(isReady: !me.isReady);
|
||||
notifyListeners();
|
||||
|
||||
final payload = {
|
||||
'type': 'TOGGLE_READY',
|
||||
'userId': me.id,
|
||||
'isReady': me.isReady,
|
||||
};
|
||||
sendMessage(payload);
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkAllReadyAndStart() {
|
||||
if (guestList.isEmpty) return;
|
||||
if (!me.isReady) return;
|
||||
|
||||
bool allGuestsReady = guestList.every((u) => u.isReady);
|
||||
|
||||
if (allGuestsReady) {
|
||||
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
final startPayload = {'type': 'GAME_START', 'gameId': 'quiz_ox'};
|
||||
sendMessage(startPayload);
|
||||
_messageController.add(startPayload);
|
||||
_resetAllReadyState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAllReadyState() {
|
||||
me = me.copyWith(isReady: false);
|
||||
for (int i = 0; i < guestList.length; i++) {
|
||||
guestList[i] = guestList[i].copyWith(isReady: false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Host Logic
|
||||
// ------------------------------------------------------------------------
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(force: true);
|
||||
role = NetworkRole.host;
|
||||
|
||||
try {
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
this.hostPort = port;
|
||||
|
||||
String? myIp = await _getWifiIp();
|
||||
this.hostIp = myIp ?? '127.0.0.1';
|
||||
_log("✅ 방 생성: $hostIp : $port");
|
||||
|
||||
_serverSocket!.listen((Socket client) {
|
||||
_handleNewGuest(client);
|
||||
});
|
||||
|
||||
// C. mDNS로 방 광고 (Broadcast)
|
||||
// 서비스 타입은 고유해야 함 (_playwith._tcp)
|
||||
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
|
||||
_bonsoirService = BonsoirService(
|
||||
name: '$roomName#${me.id}',
|
||||
name: '$roomName#${me.id}',
|
||||
type: '_playwith._tcp',
|
||||
port: port,
|
||||
attributes: {'ip': myIp},
|
||||
attributes: {'ip': hostIp!},
|
||||
);
|
||||
|
||||
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
||||
await _bonsoirBroadcast!.ready;
|
||||
await _bonsoirBroadcast!.start();
|
||||
|
||||
print('[Host] Start advertising room: $roomName');
|
||||
// [NEW] 미디어 DB 초기화 (Host)
|
||||
await MediaManager().initialize(roomName);
|
||||
|
||||
_startHeartbeat();
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Host] Error starting host: $e');
|
||||
stopNetwork();
|
||||
_log("❌ 방 생성 실패: $e");
|
||||
stopNetwork(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로운 게스트가 접속했을 때
|
||||
void _handleNewGuest(Socket client) {
|
||||
print('[Host] New guest connected: ${client.remoteAddress.address}');
|
||||
_connectedGuests.add(client);
|
||||
|
||||
// 데이터 수신 리스너 부착
|
||||
_log("🎉 연결됨: ${client.remoteAddress.address}");
|
||||
_connectedGuests[client] = null;
|
||||
|
||||
client.listen(
|
||||
(Uint8List data) => _onDataReceived(client, data),
|
||||
onError: (e) => _removeGuest(client),
|
||||
@ -126,163 +185,289 @@ final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
}
|
||||
|
||||
void _removeGuest(Socket client) {
|
||||
print('[Host] Guest disconnected');
|
||||
final UserInfo? user = _connectedGuests[client];
|
||||
if (user != null) {
|
||||
_log("👋 퇴장: ${user.nickname}");
|
||||
guestList.removeWhere((u) => u.id == user.id);
|
||||
}
|
||||
_connectedGuests.remove(client);
|
||||
client.close();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 4. Guest Logic (참가자)
|
||||
// Guest Logic
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 주변 방 찾기 (mDNS Discovery)
|
||||
Stream<List<BonsoirService>> discoverRooms() {
|
||||
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
|
||||
final controller = StreamController<List<BonsoirService>>();
|
||||
final List<BonsoirService> foundServices = [];
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
|
||||
_bonsoirDiscovery!.ready.then((_) {
|
||||
_bonsoirDiscovery!.start();
|
||||
|
||||
_bonsoirDiscovery!.eventStream!.listen((event) {
|
||||
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
|
||||
if (event.service != null) {
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
} else if (event.type == BonsoirDiscoveryEventType.discoveryServiceLost) {
|
||||
if (event.service != null) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
Future(() async {
|
||||
try {
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
await _bonsoirDiscovery!.start();
|
||||
|
||||
if (_bonsoirDiscovery?.eventStream != null) {
|
||||
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
|
||||
final String type = event.type.toString();
|
||||
if (event.service == null) return;
|
||||
|
||||
if (type.contains('Found')) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
} else if (type.contains('Lost')) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
_log("스캔 실패: $e");
|
||||
}
|
||||
});
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// 방 접속하기
|
||||
Future<void> joinRoom(String ip, int port) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
if (role != NetworkRole.guest) stopNetwork(force: true);
|
||||
role = NetworkRole.guest;
|
||||
hostIp = ip;
|
||||
hostPort = port;
|
||||
|
||||
try {
|
||||
print('[Guest] Connecting to $ip:$port...');
|
||||
_clientSocket = await Socket.connect(ip, port);
|
||||
print('[Guest] Connected!');
|
||||
_log("🚀 접속 시도: $ip:$port");
|
||||
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
|
||||
_log("✅ 접속 성공!");
|
||||
|
||||
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
|
||||
sendMessage({
|
||||
'type': 'HANDSHAKE',
|
||||
'senderId': me.id,
|
||||
'payload': me.toJson(),
|
||||
});
|
||||
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
|
||||
|
||||
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
|
||||
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
||||
|
||||
_lastPongTime = DateTime.now();
|
||||
_startHeartbeat();
|
||||
_cancelDisconnectTimer();
|
||||
|
||||
// 데이터 수신 리스너
|
||||
_clientSocket!.listen(
|
||||
(Uint8List data) => _onDataReceived(_clientSocket!, data),
|
||||
onError: (e) {
|
||||
print('[Guest] Connection error: $e');
|
||||
stopNetwork();
|
||||
},
|
||||
onDone: () {
|
||||
print('[Guest] Disconnected by host');
|
||||
stopNetwork();
|
||||
},
|
||||
onError: (e) => _handleConnectionLost(e),
|
||||
onDone: () => _handleConnectionLost("Socket Closed"),
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Guest] Failed to join: $e');
|
||||
role = NetworkRole.none;
|
||||
notifyListeners();
|
||||
rethrow; // UI에서 에러 처리할 수 있게 던짐
|
||||
_log("❌ 접속 실패: $e");
|
||||
if (!_isReconnecting) stopNetwork(force: true);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 5. Common Logic (데이터 송수신)
|
||||
// 데이터 송수신 & 라우팅 (핵심)
|
||||
// ------------------------------------------------------------------------
|
||||
void sendPacket(PlayPacket packet) {
|
||||
sendMessage(packet.toJson());
|
||||
}
|
||||
|
||||
/// 메시지 전송
|
||||
void sendMessage(Map<String, dynamic> messageMap) {
|
||||
try {
|
||||
// JSON 변환
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
// 패킷 경계 처리를 위해 끝에 줄바꿈(\n) 추가 (가장 간단한 delimiter)
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
if (role == NetworkRole.guest && _clientSocket == null) return;
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
// Host는 모든 Guest에게 브로드캐스트
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else if (role == NetworkRole.guest) {
|
||||
// Guest는 Host에게 전송
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Network] Send Error: $e');
|
||||
}
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
// 로그 필터링
|
||||
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
|
||||
if (messageMap['type'] == 'chat') {
|
||||
_log("📤 전송: [CHAT]");
|
||||
} else if (messageMap['type'] == 'media') {
|
||||
_log("📤 전송: [MEDIA]");
|
||||
} else {
|
||||
_log("📤 전송: $jsonString");
|
||||
}
|
||||
}
|
||||
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
if (role == NetworkRole.host) {
|
||||
for (var socket in _connectedGuests.keys) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else {
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 수신 처리
|
||||
void _onDataReceived(Socket socket, Uint8List data) {
|
||||
// 들어온 데이터를 String으로 변환
|
||||
final String rawString = utf8.decode(data);
|
||||
|
||||
// TCP 패킷이 뭉쳐서 올 수 있으므로 \n으로 쪼갬
|
||||
final List<String> splitMessages = rawString.split('\n');
|
||||
|
||||
|
||||
for (var msg in splitMessages) {
|
||||
if (msg.trim().isEmpty) continue;
|
||||
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> parsedData = jsonDecode(msg);
|
||||
|
||||
// 1. 앱 로직으로 전달
|
||||
_messageController.add(parsedData);
|
||||
final Map<String, dynamic> jsonMap = jsonDecode(msg);
|
||||
|
||||
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
|
||||
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
|
||||
// if (role == NetworkRole.host) { sendMessage(parsedData); }
|
||||
// 1. 시스템 메시지 (Ping/Pong)
|
||||
if (jsonMap['type'] == 'PING') {
|
||||
sendMessage({'type': 'PONG'});
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
if (jsonMap['type'] == 'PONG') {
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 핸드셰이크
|
||||
if (jsonMap['type'] == 'HANDSHAKE') {
|
||||
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
|
||||
_connectedGuests[socket] = guestInfo;
|
||||
guestList.removeWhere((u) => u.id == guestInfo.id);
|
||||
guestList.add(guestInfo);
|
||||
notifyListeners();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 레디 토글
|
||||
if (jsonMap['type'] == 'TOGGLE_READY') {
|
||||
final String userId = jsonMap['userId'];
|
||||
final bool isReady = jsonMap['isReady'];
|
||||
|
||||
final index = guestList.indexWhere((u) => u.id == userId);
|
||||
if (index != -1) {
|
||||
guestList[index] = guestList[index].copyWith(isReady: isReady);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
sendMessage(jsonMap);
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 게임 시작
|
||||
if (jsonMap['type'] == 'GAME_START') {
|
||||
_resetAllReadyState();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 패킷 라우팅 (Chat, Media, Game)
|
||||
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
|
||||
final packet = PlayPacket.fromJson(jsonMap);
|
||||
|
||||
// [라우팅] 채팅 -> GlobalChatManager
|
||||
if (packet.type == PacketType.chat) {
|
||||
GlobalChatManager().onPacketReceived(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
// [라우팅] 미디어 -> MediaManager
|
||||
if (packet.type == PacketType.media) {
|
||||
MediaManager().onMediaReceived(packet);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 그 외 게임 데이터
|
||||
_messageController.add(jsonMap);
|
||||
|
||||
} catch (e) {
|
||||
print('[Network] Parse Error: $e\nMessage: $msg');
|
||||
_log("파싱 에러: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 6. Cleanup
|
||||
// 연결 관리
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 네트워크 종료 및 리소스 정리
|
||||
void stopNetwork() {
|
||||
print('[Network] Stopping network...');
|
||||
void _handleConnectionLost(dynamic reason) {
|
||||
if (role != NetworkRole.guest) return;
|
||||
_log("⚠️ 연결 끊김: $reason");
|
||||
|
||||
// mDNS 중지
|
||||
_clientSocket?.destroy();
|
||||
_clientSocket = null;
|
||||
|
||||
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
|
||||
|
||||
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
|
||||
_log("💀 복구 실패. 종료.");
|
||||
stopNetwork(force: true);
|
||||
});
|
||||
_attemptReconnection();
|
||||
}
|
||||
|
||||
Future<void> _attemptReconnection() async {
|
||||
if (hostIp == null || hostPort == null) return;
|
||||
_isReconnecting = true;
|
||||
while (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) {
|
||||
try {
|
||||
await joinRoom(hostIp!, hostPort!);
|
||||
_log("✅ 재접속 성공!");
|
||||
_isReconnecting = false;
|
||||
return;
|
||||
} catch (e) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
_isReconnecting = false;
|
||||
}
|
||||
|
||||
void _cancelDisconnectTimer() {
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: HEARTBEAT_INTERVAL_SEC), (timer) {
|
||||
try { sendMessage({'type': 'PING'}); } catch (_) {}
|
||||
if (role == NetworkRole.guest && _lastPongTime != null) {
|
||||
if (DateTime.now().difference(_lastPongTime!).inSeconds > TIMEOUT_SEC) {
|
||||
_handleConnectionLost("Heartbeat Timeout");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _getWifiIp() async {
|
||||
try {
|
||||
for (var interface in await NetworkInterface.list()) {
|
||||
if (interface.name.contains('wlan') || interface.name.contains('en') || interface.name.contains('ap')) {
|
||||
for (var addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {/**/}
|
||||
return null;
|
||||
}
|
||||
|
||||
void stopNetwork({bool force = false}) {
|
||||
if (!force && _disconnectWaitTimer != null) return;
|
||||
|
||||
_log("🛑 종료");
|
||||
// [NEW] 미디어 DB 정리
|
||||
MediaManager().cleanup();
|
||||
|
||||
_heartbeatTimer?.cancel();
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
// 소켓 닫기
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.close();
|
||||
}
|
||||
for (var s in _connectedGuests.keys) s.close();
|
||||
_connectedGuests.clear();
|
||||
|
||||
guestList.clear();
|
||||
role = NetworkRole.none;
|
||||
_serverSocket = null;
|
||||
_clientSocket = null;
|
||||
|
||||
if (force) { hostIp = null; hostPort = null; }
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
// lib/playwith_core.dart
|
||||
|
||||
library playwith_core;
|
||||
|
||||
export 'game/base_game.dart';
|
||||
export 'network/network_manager.dart';
|
||||
export 'model/user_info.dart';
|
||||
export 'model/user_info.dart';
|
||||
export 'model/play_packet.dart';
|
||||
export 'utils/sound_manager.dart';
|
||||
export 'manager/global_chat_manager.dart';
|
||||
export 'widgets/game_chat_overlay.dart';
|
||||
|
||||
// [추가] DB 관련 (Drift가 생성한 데이터 클래스들도 쓰기 위해)
|
||||
export 'database/ephemeral_database.dart';
|
||||
// Drift의 기본 타입(Value 등)을 쓰려면 아래 줄도 필요할 수 있음 (선택)
|
||||
export 'package:drift/drift.dart' show Value;
|
||||
65
packages/core/lib/utils/sound_manager.dart
Normal file
65
packages/core/lib/utils/sound_manager.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
|
||||
/// 사운드 키 상수 (오타 방지용)
|
||||
class SoundKey {
|
||||
static const String bgm = 'bgm';
|
||||
static const String correct = 'correct';
|
||||
static const String wrong = 'wrong';
|
||||
static const String win = 'win';
|
||||
static const String click = 'click';
|
||||
}
|
||||
|
||||
class SoundManager {
|
||||
static final SoundManager _instance = SoundManager._internal();
|
||||
factory SoundManager() => _instance;
|
||||
SoundManager._internal();
|
||||
|
||||
final AudioPlayer _bgmPlayer = AudioPlayer();
|
||||
final AudioPlayer _sfxPlayer = AudioPlayer();
|
||||
|
||||
// [핵심] 키-경로 매핑 저장소
|
||||
final Map<String, String> _soundPaths = {};
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 앱 시작 시 사운드 경로 주입 (Dependency Injection)
|
||||
void initialize({required Map<String, String> soundPaths}) {
|
||||
_soundPaths.addAll(soundPaths);
|
||||
_isInitialized = true;
|
||||
print('[SoundManager] Initialized with ${_soundPaths.length} sounds');
|
||||
}
|
||||
|
||||
/// BGM 재생
|
||||
Future<void> playBgm(String key) async {
|
||||
if (!_isInitialized) return;
|
||||
final path = _soundPaths[key];
|
||||
|
||||
if (path != null) {
|
||||
await _bgmPlayer.setReleaseMode(ReleaseMode.loop);
|
||||
await _bgmPlayer.setVolume(0.3);
|
||||
// AssetSource는 'assets/'를 생략하고 그 하위 경로를 입력받습니다.
|
||||
// 예: assets/audio/bgm.mp3 -> AssetSource('audio/bgm.mp3')
|
||||
await _bgmPlayer.play(AssetSource(path));
|
||||
} else {
|
||||
print('[SoundManager] BGM Key not found: $key');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopBgm() async {
|
||||
await _bgmPlayer.stop();
|
||||
}
|
||||
|
||||
/// 효과음 재생
|
||||
Future<void> playSfx(String key) async {
|
||||
if (!_isInitialized) return;
|
||||
final path = _soundPaths[key];
|
||||
|
||||
if (path != null) {
|
||||
// 효과음은 겹칠 수 있으므로 매번 stop 하거나 모드 설정
|
||||
await _sfxPlayer.stop();
|
||||
await _sfxPlayer.setVolume(1.0);
|
||||
await _sfxPlayer.play(AssetSource(path));
|
||||
} else {
|
||||
print('[SoundManager] SFX Key not found: $key');
|
||||
}
|
||||
}
|
||||
}
|
||||
229
packages/core/lib/widgets/game_chat_overlay.dart
Normal file
229
packages/core/lib/widgets/game_chat_overlay.dart
Normal file
@ -0,0 +1,229 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart'; // 미디어 매니저
|
||||
import '../database/ephemeral_database.dart'; // DB 모델
|
||||
|
||||
class GameChatOverlay extends StatefulWidget {
|
||||
const GameChatOverlay({super.key});
|
||||
|
||||
@override
|
||||
State<GameChatOverlay> createState() => _GameChatOverlayState();
|
||||
}
|
||||
|
||||
class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
// 높이 조정: 미디어 갤러리가 보일 공간 확보 (펼쳤을 때)
|
||||
height: _isExpanded ? 500 : 60,
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2)],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. 상단 핸들 (접기/펼치기)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 펼쳤을 때만 보이는 영역
|
||||
if (_isExpanded) ...[
|
||||
|
||||
// 2. 미디어 갤러리 (가로 스크롤)
|
||||
// DB의 변경사항을 실시간으로 감지(Stream)하여 보여줌
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: StreamBuilder<List<MediaItem>>(
|
||||
stream: MediaManager().galleryStream,
|
||||
builder: (context, snapshot) {
|
||||
final mediaList = snapshot.data ?? [];
|
||||
|
||||
if (mediaList.isEmpty) {
|
||||
return const Center(child: Text("공유된 미디어가 없습니다.", style: TextStyle(color: Colors.white54, fontSize: 12)));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: mediaList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = mediaList[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, item),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(item.filePath),
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_,__,___) => Container(
|
||||
width: 100, height: 100, color: Colors.grey,
|
||||
child: const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white24),
|
||||
|
||||
// 3. 채팅 리스트
|
||||
Expanded(
|
||||
child: StreamBuilder<List<ChatMessage>>(
|
||||
stream: GlobalChatManager().messageStream,
|
||||
builder: (context, snapshot) {
|
||||
final messages = snapshot.data ?? [];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
"${msg.senderName}: ${msg.text}",
|
||||
style: TextStyle(
|
||||
color: msg.isMe ? Colors.yellow : Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 4. 입력창 (+ 미디어 버튼)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// [추가] 이미지 전송 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
||||
onPressed: _pickAndSendImage,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "채팅 입력...",
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: _sendMessage,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, color: Colors.blue),
|
||||
onPressed: () => _sendMessage(_textController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage(String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
GlobalChatManager().sendMessage(text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
// [이미지 선택 및 전송 로직]
|
||||
Future<void> _pickAndSendImage() async {
|
||||
final picker = ImagePicker();
|
||||
// 갤러리에서 이미지 선택 (압축 옵션 추가 권장)
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 50, // 전송 속도를 위해 품질 낮춤
|
||||
maxWidth: 800,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
// MediaManager를 통해 전송
|
||||
await MediaManager().sendMedia(
|
||||
filePath: image.path,
|
||||
type: 'IMAGE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [이미지 크게 보기 팝업]
|
||||
void _showFullImage(BuildContext context, MediaItem item) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
child: Image.file(File(item.filePath)),
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 30),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: Colors.black54,
|
||||
child: Text("보낸 사람: ${item.senderName}", style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,9 +8,21 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# 네트워크 디스커버리 (mDNS)
|
||||
bonsoir: ^2.0.0
|
||||
# 고유 ID 생성
|
||||
# 기존 ^2.0.0 등을 지우고 최신 버전으로 변경
|
||||
bonsoir: ^6.0.1
|
||||
uuid: ^4.0.0
|
||||
# 데이터 비교 및 불변성 (선택사항이지만 추천)
|
||||
equatable: ^2.0.5
|
||||
equatable: ^2.0.5
|
||||
permission_handler: ^11.0.0
|
||||
# [DB]
|
||||
drift: ^2.13.0
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
path_provider: ^2.1.1
|
||||
path: ^1.8.3
|
||||
|
||||
# [파일 피커]
|
||||
image_picker: ^1.0.4
|
||||
file_picker: ^6.1.1
|
||||
|
||||
dev_dependencies:
|
||||
drift_dev: ^2.13.0
|
||||
build_runner: ^2.4.6
|
||||
618
packages/games/quiz/lib/quiz_game.dart
Normal file
618
packages/games/quiz/lib/quiz_game.dart
Normal file
@ -0,0 +1,618 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:playwith_core/playwith_core.dart';
|
||||
|
||||
enum PlayerStatus { alive, dead, winner, loser }
|
||||
|
||||
class QuizGame extends BaseGame {
|
||||
@override
|
||||
String get id => "quiz_ox";
|
||||
|
||||
@override
|
||||
String get name => "OX 퀴즈 서바이벌";
|
||||
|
||||
@override
|
||||
String get description => "방장도 플레이어! 3초 안에 선택하세요.";
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 상태 변수
|
||||
// ------------------------------------------------------------------------
|
||||
final _gameStateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get gameStateStream => _gameStateController.stream;
|
||||
StreamSubscription? _networkSubscription;
|
||||
|
||||
// UI 초기화 지연 방지용 데이터
|
||||
Map<String, dynamic>? _lastState;
|
||||
|
||||
// 게임 데이터
|
||||
final Set<String> _aliveUsers = {}; // 생존자 ID
|
||||
final Set<String> _answeredUsers = {}; // 답변 제출자 ID
|
||||
|
||||
// 나의 상태
|
||||
PlayerStatus _myStatus = PlayerStatus.alive;
|
||||
String? _mySelectedAnswer;
|
||||
bool _isLockedIn = false;
|
||||
Timer? _lockInTimer;
|
||||
|
||||
// 카운트다운 상태
|
||||
bool _isCountingDown = false;
|
||||
int _countdownValue = 3;
|
||||
|
||||
final List<Map<String, dynamic>> _questions = [
|
||||
{"q": "사과는 영어로 Apple이다.", "a": "O"},
|
||||
{"q": "바나나는 길어지면 기차다.", "a": "X"},
|
||||
{"q": "플러터는 구글이 만들었다.", "a": "O"},
|
||||
{"q": "지범님은 천재 개발자다.", "a": "O"},
|
||||
{"q": "북극곰의 피부색은 검은색이다.", "a": "O"},
|
||||
{"q": "타조는 날 수 있다.", "a": "X"},
|
||||
];
|
||||
|
||||
int _currentQuestionIndex = -1;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 라이프사이클
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
void onStart() {
|
||||
print("Quiz Game Started!");
|
||||
_resetLocalState();
|
||||
_lastState = null;
|
||||
_aliveUsers.clear();
|
||||
|
||||
// 참가자 명단 초기화 (나 + 게스트)
|
||||
_aliveUsers.add(NetworkManager().me.id);
|
||||
for (var guest in NetworkManager().guestList) {
|
||||
_aliveUsers.add(guest.id);
|
||||
}
|
||||
|
||||
_networkSubscription = NetworkManager().messageStream.listen((payload) {
|
||||
onMessageReceived("", payload);
|
||||
});
|
||||
|
||||
// [Host] 잠시 후 카운트다운 시작
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
_startCountdown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onDispose() {
|
||||
_lockInTimer?.cancel();
|
||||
_networkSubscription?.cancel();
|
||||
_gameStateController.close();
|
||||
}
|
||||
|
||||
// [Host] 카운트다운
|
||||
void _startCountdown() {
|
||||
if (_currentQuestionIndex != -1) return;
|
||||
|
||||
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
int nextCount = 3 - timer.tick;
|
||||
if (nextCount > 0) {
|
||||
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': nextCount});
|
||||
} else {
|
||||
timer.cancel();
|
||||
_nextQuestion(); // 첫 문제 출제
|
||||
}
|
||||
});
|
||||
_broadcastState({'type': 'GAME_COUNTDOWN', 'count': 3});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 메시지 처리 (Logic)
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload) {
|
||||
|
||||
if (payload['type'] != 'ANSWER_SUBMIT') {
|
||||
_lastState = payload;
|
||||
}
|
||||
|
||||
// 1. [Common] 카운트다운
|
||||
if (payload['type'] == 'GAME_COUNTDOWN') {
|
||||
_isCountingDown = true;
|
||||
_countdownValue = payload['count'];
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
// 2. [Host Logic] 답변 제출 처리
|
||||
if (payload['type'] == 'ANSWER_SUBMIT') {
|
||||
if (NetworkManager().role != NetworkRole.host) return;
|
||||
|
||||
final String userId = payload['userId'];
|
||||
final String answer = payload['answer'];
|
||||
|
||||
if (!_aliveUsers.contains(userId)) return;
|
||||
if (_answeredUsers.contains(userId)) return;
|
||||
|
||||
_answeredUsers.add(userId);
|
||||
|
||||
// 정답 체크 (결과는 바로 반영하되, 탈락 통보는 결과 화면 때 보냄)
|
||||
final currentAnswer = _questions[_currentQuestionIndex]['a'];
|
||||
bool isCorrect = (answer == currentAnswer);
|
||||
|
||||
if (!isCorrect) {
|
||||
_aliveUsers.remove(userId); // 명단에서는 제거하되, 통보는 나중에
|
||||
}
|
||||
|
||||
// 제출 현황 전파
|
||||
_broadcastState({
|
||||
'type': 'PLAYER_STATUS_UPDATE',
|
||||
'userId': userId,
|
||||
'isSubmitted': true,
|
||||
'isAlive': true // 아직은 살아있는 척 (결과 화면에서 공개)
|
||||
});
|
||||
|
||||
// [핵심 변경] 전원 제출 완료 시 -> 결과 발표 화면으로 이동
|
||||
// (방금 죽은 사람 포함해서 이번 라운드 시작 인원만큼 답변이 왔는지 체크)
|
||||
int currentRoundPlayers = _aliveUsers.length + (isCorrect ? 0 : 1); // 방금 뺀 사람 포함
|
||||
// 더 정확히는: answeredUsers가 이번 라운드 참가자 수에 도달하면 진행
|
||||
// (여기선 간단히 answeredUsers가 더이상 늘어날 수 없을 때로 판단)
|
||||
|
||||
// 타임아웃 로직이 없으므로, 현재 살아있는 사람들이 다 냈으면 진행
|
||||
// (로직이 복잡해질 수 있으므로, 간단히 '살아있는 사람 수 == 답변 수'가 아니라
|
||||
// '이번 라운드 시작 시점의 생존자 수'를 별도 변수로 관리하는 게 정석이지만,
|
||||
// 여기서는 생존자 수 + 이번에 틀린 사람 수로 계산)
|
||||
|
||||
// 간단 로직: 1초 뒤 체크해서 더 낼 사람이 없으면 진행 (혹은 모두 냈으면 바로)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
// 대충 모두 냈다고 판단되면 (추가 보정 필요할 수 있음)
|
||||
if (_answeredUsers.length >= (_aliveUsers.length + (isCorrect?0:1))) {
|
||||
_showRoundResult();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. [Common] 중간 결과 발표 (NEW)
|
||||
if (payload['type'] == 'ROUND_RESULT') {
|
||||
_isCountingDown = false;
|
||||
final bool isSurvived = payload['survivors'].contains(NetworkManager().me.id);
|
||||
|
||||
// 내 생존 여부 업데이트
|
||||
if (!isSurvived && _myStatus == PlayerStatus.alive) {
|
||||
_handleElimination();
|
||||
}
|
||||
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
// 4. [Common] 플레이어 상태 업데이트
|
||||
if (payload['type'] == 'PLAYER_STATUS_UPDATE') {
|
||||
final userId = payload['userId'];
|
||||
_answeredUsers.add(userId);
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
// 5. [Common] 새 문제 시작
|
||||
if (payload['type'] == 'GAME_STATE_UPDATE' && payload['status'] == 'QUESTION') {
|
||||
_isCountingDown = false;
|
||||
_resetLocalState();
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
|
||||
// 6. [Common] 종료
|
||||
if (payload['type'] == 'GAME_OVER' || payload['type'] == 'GAME_EXIT') {
|
||||
if (payload['type'] == 'GAME_OVER') {
|
||||
final winnerId = payload['winnerId'];
|
||||
_myStatus = (winnerId == NetworkManager().me.id) ? PlayerStatus.winner : PlayerStatus.loser;
|
||||
if (_myStatus == PlayerStatus.winner) SoundManager().playSfx(SoundKey.win);
|
||||
}
|
||||
_gameStateController.add(payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleElimination() {
|
||||
SoundManager().playSfx(SoundKey.wrong);
|
||||
_myStatus = PlayerStatus.dead;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [Host Logic]
|
||||
// ------------------------------------------------------------------------
|
||||
// [NEW] 결과 발표 단계
|
||||
void _showRoundResult() {
|
||||
final currentQ = _questions[_currentQuestionIndex];
|
||||
|
||||
final resultData = {
|
||||
'type': 'ROUND_RESULT',
|
||||
'status': 'RESULT',
|
||||
'correctAnswer': currentQ['a'],
|
||||
'survivors': _aliveUsers.toList(), // 생존자 명단 전송
|
||||
};
|
||||
|
||||
_broadcastState(resultData);
|
||||
|
||||
// 3초 뒤 다음 문제로 자동 이동
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
_nextQuestion();
|
||||
});
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
// 승패 판정
|
||||
int totalStartPlayers = NetworkManager().guestList.length + 1;
|
||||
if ((totalStartPlayers > 1 && _aliveUsers.length <= 1) || _currentQuestionIndex >= _questions.length - 1) {
|
||||
String? winnerId;
|
||||
if (_aliveUsers.isNotEmpty) winnerId = _aliveUsers.first;
|
||||
_finishGame(winnerId: winnerId);
|
||||
return;
|
||||
}
|
||||
|
||||
_currentQuestionIndex++;
|
||||
final questionData = _questions[_currentQuestionIndex];
|
||||
|
||||
final stateData = {
|
||||
'type': 'GAME_STATE_UPDATE',
|
||||
'status': 'QUESTION',
|
||||
'data': questionData
|
||||
};
|
||||
|
||||
_resetLocalState();
|
||||
_broadcastState(stateData);
|
||||
}
|
||||
|
||||
void _finishGame({String? winnerId}) {
|
||||
final endData = {
|
||||
'type': 'GAME_OVER',
|
||||
'winnerId': winnerId ?? 'NONE',
|
||||
'winnerName': _findUserName(winnerId)
|
||||
};
|
||||
_broadcastState(endData);
|
||||
}
|
||||
|
||||
void _broadcastState(Map<String, dynamic> data) {
|
||||
_lastState = data;
|
||||
_gameStateController.add(data);
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
NetworkManager().sendMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _resetLocalState() {
|
||||
_answeredUsers.clear();
|
||||
_mySelectedAnswer = null;
|
||||
_isLockedIn = false;
|
||||
_lockInTimer?.cancel();
|
||||
}
|
||||
|
||||
String _findUserName(String? id) {
|
||||
if (id == null) return '없음';
|
||||
if (id == NetworkManager().me.id) return NetworkManager().me.nickname;
|
||||
return NetworkManager().guestList.firstWhere((u) => u.id == id, orElse: () => UserInfo(id: '', nickname: 'Unknown')).nickname;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [UI] Unified View
|
||||
// ------------------------------------------------------------------------
|
||||
@override
|
||||
Widget buildHostView(BuildContext context) => _buildGameScreen(context, isHost: true);
|
||||
|
||||
@override
|
||||
Widget buildGuestView(BuildContext context) => _buildGameScreen(context, isHost: false);
|
||||
|
||||
Widget _buildGameScreen(BuildContext context, {required bool isHost}) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("OX 서바이벌"),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
if (isHost) IconButton(icon: const Icon(Icons.power_settings_new), onPressed: () => _confirmExit(context))
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<Map<String, dynamic>>(
|
||||
stream: gameStateStream,
|
||||
initialData: _lastState,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return _buildWaitingScreen("로딩 중...");
|
||||
|
||||
final data = snapshot.data!;
|
||||
|
||||
if (data['type'] == 'GAME_COUNTDOWN') {
|
||||
int count = data['count'] ?? 3;
|
||||
return Center(child: Text("$count", style: const TextStyle(fontSize: 120, fontWeight: FontWeight.bold, color: Colors.blueAccent)));
|
||||
}
|
||||
|
||||
if (data['type'] == 'GAME_EXIT') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { if(context.mounted) Navigator.pop(context); });
|
||||
return const Center(child: Text("종료 중..."));
|
||||
}
|
||||
if (data['type'] == 'GAME_OVER') {
|
||||
return _buildResultScreen(context, data['winnerName']);
|
||||
}
|
||||
|
||||
// [NEW] 중간 결과 화면
|
||||
if (data['status'] == 'RESULT') {
|
||||
return _buildRoundResultScreen(data);
|
||||
}
|
||||
|
||||
// 문제 풀이 화면
|
||||
if (data['status'] == 'QUESTION' || _currentQuestionIndex >= 0) {
|
||||
Map<String, dynamic> qData = data['data'] ?? _questions[_currentQuestionIndex];
|
||||
int answered = data['answeredCount'] ?? _answeredUsers.length;
|
||||
// 총인원 계산 (생존자 기준이 아님, 이번 라운드 참여자 기준이어야 함. 여기선 간단히 전체 인원 사용)
|
||||
int total = NetworkManager().guestList.length + 1;
|
||||
|
||||
return _buildPlayArea(context, qData, answered, total);
|
||||
}
|
||||
|
||||
return _buildWaitingScreen("대기 중...");
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// [NEW] 중간 결과 화면 위젯
|
||||
Widget _buildRoundResultScreen(Map<String, dynamic> data) {
|
||||
final String correctAnswer = data['correctAnswer'];
|
||||
final List<dynamic> survivors = data['survivors'] ?? [];
|
||||
final bool amISurvived = survivors.contains(NetworkManager().me.id);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("정답은?", style: TextStyle(fontSize: 24, color: Colors.grey)),
|
||||
const SizedBox(height: 20),
|
||||
// 정답 표시
|
||||
Container(
|
||||
width: 150, height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: correctAnswer == "O" ? Colors.blue : Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(child: Text(correctAnswer, style: const TextStyle(fontSize: 80, color: Colors.white, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 생존 여부 메시지
|
||||
if (_myStatus == PlayerStatus.dead)
|
||||
const Text("이미 탈락하셨습니다. 👻", style: TextStyle(fontSize: 20, color: Colors.grey))
|
||||
else if (amISurvived)
|
||||
const Text("생존! 다음 라운드 진출! 🎉", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green))
|
||||
else
|
||||
const Text("탈락했습니다... 😭", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.red)),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Text("잠시 후 다음 문제가 시작됩니다.", style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayArea(BuildContext context, Map<String, dynamic> qData, int answered, int total) {
|
||||
if (_myStatus == PlayerStatus.dead) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.sentiment_dissatisfied, size: 80, color: Colors.grey),
|
||||
const SizedBox(height: 20),
|
||||
const Text("탈락했습니다 👻", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 20),
|
||||
Text("관전 중... ($answered명 제출)", style: const TextStyle(fontSize: 18, color: Colors.grey)),
|
||||
const SizedBox(height: 40),
|
||||
Text("문제: ${qData['q']}", style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 상단 현황판
|
||||
_PlayerStatusGrid(aliveUsers: _aliveUsers, answeredUsers: _answeredUsers),
|
||||
const Divider(),
|
||||
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
qData['q'],
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, height: 1.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _isLockedIn
|
||||
? _buildLockedUI()
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_AnswerBtn(text: "O", color: Colors.blue, isSelected: _mySelectedAnswer == "O", onTap: () => _selectAnswer("O")),
|
||||
_AnswerBtn(text: "X", color: Colors.red, isSelected: _mySelectedAnswer == "X", onTap: () => _selectAnswer("X")),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: _mySelectedAnswer != null && !_isLockedIn
|
||||
? const Text("3초 후 확정됩니다! (변경 가능)", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ... (이하 _buildLockedUI, _selectAnswer 등 기존 함수들 유지) ...
|
||||
|
||||
Widget _buildLockedUI() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_mySelectedAnswer == "O" ? Icons.circle_outlined : Icons.close,
|
||||
size: 80,
|
||||
color: _mySelectedAnswer == "O" ? Colors.blue : Colors.red,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text("제출 완료! 결과를 기다리는 중...", style: TextStyle(fontSize: 20, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectAnswer(String answer) {
|
||||
_lockInTimer?.cancel();
|
||||
_mySelectedAnswer = answer;
|
||||
SoundManager().playSfx(SoundKey.click);
|
||||
_updateLocalState({'type': 'UI_REFRESH'});
|
||||
|
||||
_lockInTimer = Timer(const Duration(seconds: 3), () {
|
||||
_submitFinalAnswer();
|
||||
});
|
||||
}
|
||||
|
||||
void _submitFinalAnswer() {
|
||||
if (_mySelectedAnswer == null) return;
|
||||
_isLockedIn = true;
|
||||
_updateLocalState({'type': 'UI_REFRESH'});
|
||||
|
||||
final payload = {
|
||||
'type': 'ANSWER_SUBMIT',
|
||||
'answer': _mySelectedAnswer,
|
||||
'userId': NetworkManager().me.id,
|
||||
};
|
||||
|
||||
if (NetworkManager().role == NetworkRole.host) {
|
||||
onMessageReceived("", payload);
|
||||
} else {
|
||||
NetworkManager().sendMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateLocalState(Map<String, dynamic> data) {
|
||||
_gameStateController.add(data);
|
||||
}
|
||||
|
||||
Widget _buildResultScreen(BuildContext context, String winnerName) {
|
||||
bool amIWinner = _myStatus == PlayerStatus.winner;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(amIWinner ? Icons.emoji_events : Icons.thumb_down, size: 100, color: amIWinner ? Colors.amber : Colors.grey),
|
||||
const SizedBox(height: 20),
|
||||
Text(amIWinner ? "우승!" : "게임 종료", style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
Text("최종 우승자: $winnerName", style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 50),
|
||||
ElevatedButton(onPressed: () { onDispose(); Navigator.pop(context); }, child: const Text("나가기")),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingScreen(String msg) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), SizedBox(height: 20), Text(msg)]));
|
||||
|
||||
void _confirmExit(BuildContext context) {
|
||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text("게임 종료"), content: const Text("방을 폭파하시겠습니까?"),
|
||||
actions: [
|
||||
TextButton(onPressed: ()=>Navigator.pop(ctx), child: const Text("취소")),
|
||||
TextButton(onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
NetworkManager().sendMessage({'type': 'GAME_EXIT'});
|
||||
onDispose();
|
||||
Navigator.pop(context);
|
||||
}, child: const Text("종료", style: TextStyle(color: Colors.red))),
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// [Widget] 현황판 (기존 코드와 동일하지만 함께 제공)
|
||||
// ------------------------------------------------------------------------
|
||||
class _PlayerStatusGrid extends StatelessWidget {
|
||||
final Set<String> aliveUsers;
|
||||
final Set<String> answeredUsers;
|
||||
|
||||
const _PlayerStatusGrid({required this.aliveUsers, required this.answeredUsers});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allUsers = [NetworkManager().me, ...NetworkManager().guestList];
|
||||
|
||||
return Container(
|
||||
height: 90,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
color: Colors.grey[100],
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: allUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = allUsers[index];
|
||||
final isAlive = aliveUsers.contains(user.id);
|
||||
final isSubmitted = answeredUsers.contains(user.id);
|
||||
final isMe = user.id == NetworkManager().me.id;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 50, height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isAlive ? Color(user.colorValue) : Colors.grey,
|
||||
border: isSubmitted ? Border.all(color: Colors.green, width: 3) : null,
|
||||
),
|
||||
child: Center(
|
||||
child: isAlive
|
||||
? Text(user.nickname[0], style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
|
||||
: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (isMe) Positioned(top:0, right:0, child: Container(width: 10, height: 10, decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(user.nickname, style: TextStyle(fontSize: 10, color: isAlive ? Colors.black : Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnswerBtn extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _AnswerBtn({required this.text, required this.color, required this.isSelected, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: isSelected ? 140 : 120,
|
||||
height: isSelected ? 140 : 120,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(isSelected ? 1.0 : 0.6),
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: Colors.white, width: 5) : null,
|
||||
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, offset: const Offset(0, 6))]
|
||||
),
|
||||
child: Center(child: Text(text, style: const TextStyle(fontSize: 60, color: Colors.white, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,57 +1,15 @@
|
||||
name: playwith_game_quiz
|
||||
description: "A new Flutter package project."
|
||||
description: A quiz game module.
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
flutter: ">=1.17.0"
|
||||
sdk: ^3.0.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# 코어 패키지 의존성 추가
|
||||
# Core 모듈 연결
|
||||
playwith_core:
|
||||
path: ../../core
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
audioplayers: ^6.0.0 # 여기로 이동
|
||||
Loading…
x
Reference in New Issue
Block a user