This commit is contained in:
lunaticbum 2025-11-24 17:53:00 +09:00
parent e992a5ca5e
commit dde81cab65
34 changed files with 3719 additions and 470 deletions

View File

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

View File

@ -1 +0,0 @@
{"version":2,"entries":[{"package":"playWith","rootUri":"../","packageUri":"lib/"}]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

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

View File

@ -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()),

View File

@ -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)),
],
),
),
);
}

View File

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

View File

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

View File

@ -3,6 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
sqlite3_flutter_libs
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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);
}

View File

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

View 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([]);
}
}

View 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");
}
}
}

View 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;
}
}

View File

@ -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];
}

View File

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

View File

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

View 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');
}
}
}

View 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)),
),
)
],
),
),
);
}
}

View File

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

View 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))),
),
);
}
}

View File

@ -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 # 여기로 이동