..
This commit is contained in:
@@ -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 파일 자체를 날림)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ephemeral_database.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
class $MediaItemsTable extends MediaItems
|
||||
with TableInfo<$MediaItemsTable, MediaItem> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$MediaItemsTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||
@override
|
||||
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||
'id', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _senderIdMeta =
|
||||
const VerificationMeta('senderId');
|
||||
@override
|
||||
late final GeneratedColumn<String> senderId = GeneratedColumn<String>(
|
||||
'sender_id', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _senderNameMeta =
|
||||
const VerificationMeta('senderName');
|
||||
@override
|
||||
late final GeneratedColumn<String> senderName = GeneratedColumn<String>(
|
||||
'sender_name', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _typeMeta = const VerificationMeta('type');
|
||||
@override
|
||||
late final GeneratedColumn<String> type = GeneratedColumn<String>(
|
||||
'type', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _filePathMeta =
|
||||
const VerificationMeta('filePath');
|
||||
@override
|
||||
late final GeneratedColumn<String> filePath = GeneratedColumn<String>(
|
||||
'file_path', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _createdAtMeta =
|
||||
const VerificationMeta('createdAt');
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
|
||||
'created_at', aliasedName, false,
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: currentDateAndTime);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[id, senderId, senderName, type, filePath, createdAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'media_items';
|
||||
@override
|
||||
VerificationContext validateIntegrity(Insertable<MediaItem> instance,
|
||||
{bool isInserting = false}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('id')) {
|
||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_idMeta);
|
||||
}
|
||||
if (data.containsKey('sender_id')) {
|
||||
context.handle(_senderIdMeta,
|
||||
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_senderIdMeta);
|
||||
}
|
||||
if (data.containsKey('sender_name')) {
|
||||
context.handle(
|
||||
_senderNameMeta,
|
||||
senderName.isAcceptableOrUnknown(
|
||||
data['sender_name']!, _senderNameMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_senderNameMeta);
|
||||
}
|
||||
if (data.containsKey('type')) {
|
||||
context.handle(
|
||||
_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_typeMeta);
|
||||
}
|
||||
if (data.containsKey('file_path')) {
|
||||
context.handle(_filePathMeta,
|
||||
filePath.isAcceptableOrUnknown(data['file_path']!, _filePathMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_filePathMeta);
|
||||
}
|
||||
if (data.containsKey('created_at')) {
|
||||
context.handle(_createdAtMeta,
|
||||
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
MediaItem map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return MediaItem(
|
||||
id: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}id'])!,
|
||||
senderId: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}sender_id'])!,
|
||||
senderName: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}sender_name'])!,
|
||||
type: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}type'])!,
|
||||
filePath: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}file_path'])!,
|
||||
createdAt: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$MediaItemsTable createAlias(String alias) {
|
||||
return $MediaItemsTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaItem extends DataClass implements Insertable<MediaItem> {
|
||||
final String id;
|
||||
final String senderId;
|
||||
final String senderName;
|
||||
final String type;
|
||||
final String filePath;
|
||||
final DateTime createdAt;
|
||||
const MediaItem(
|
||||
{required this.id,
|
||||
required this.senderId,
|
||||
required this.senderName,
|
||||
required this.type,
|
||||
required this.filePath,
|
||||
required this.createdAt});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<String>(id);
|
||||
map['sender_id'] = Variable<String>(senderId);
|
||||
map['sender_name'] = Variable<String>(senderName);
|
||||
map['type'] = Variable<String>(type);
|
||||
map['file_path'] = Variable<String>(filePath);
|
||||
map['created_at'] = Variable<DateTime>(createdAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
MediaItemsCompanion toCompanion(bool nullToAbsent) {
|
||||
return MediaItemsCompanion(
|
||||
id: Value(id),
|
||||
senderId: Value(senderId),
|
||||
senderName: Value(senderName),
|
||||
type: Value(type),
|
||||
filePath: Value(filePath),
|
||||
createdAt: Value(createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
factory MediaItem.fromJson(Map<String, dynamic> json,
|
||||
{ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return MediaItem(
|
||||
id: serializer.fromJson<String>(json['id']),
|
||||
senderId: serializer.fromJson<String>(json['senderId']),
|
||||
senderName: serializer.fromJson<String>(json['senderName']),
|
||||
type: serializer.fromJson<String>(json['type']),
|
||||
filePath: serializer.fromJson<String>(json['filePath']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<String>(id),
|
||||
'senderId': serializer.toJson<String>(senderId),
|
||||
'senderName': serializer.toJson<String>(senderName),
|
||||
'type': serializer.toJson<String>(type),
|
||||
'filePath': serializer.toJson<String>(filePath),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
MediaItem copyWith(
|
||||
{String? id,
|
||||
String? senderId,
|
||||
String? senderName,
|
||||
String? type,
|
||||
String? filePath,
|
||||
DateTime? createdAt}) =>
|
||||
MediaItem(
|
||||
id: id ?? this.id,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
type: type ?? this.type,
|
||||
filePath: filePath ?? this.filePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
MediaItem copyWithCompanion(MediaItemsCompanion data) {
|
||||
return MediaItem(
|
||||
id: data.id.present ? data.id.value : this.id,
|
||||
senderId: data.senderId.present ? data.senderId.value : this.senderId,
|
||||
senderName:
|
||||
data.senderName.present ? data.senderName.value : this.senderName,
|
||||
type: data.type.present ? data.type.value : this.type,
|
||||
filePath: data.filePath.present ? data.filePath.value : this.filePath,
|
||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MediaItem(')
|
||||
..write('id: $id, ')
|
||||
..write('senderId: $senderId, ')
|
||||
..write('senderName: $senderName, ')
|
||||
..write('type: $type, ')
|
||||
..write('filePath: $filePath, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, senderId, senderName, type, filePath, createdAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is MediaItem &&
|
||||
other.id == this.id &&
|
||||
other.senderId == this.senderId &&
|
||||
other.senderName == this.senderName &&
|
||||
other.type == this.type &&
|
||||
other.filePath == this.filePath &&
|
||||
other.createdAt == this.createdAt);
|
||||
}
|
||||
|
||||
class MediaItemsCompanion extends UpdateCompanion<MediaItem> {
|
||||
final Value<String> id;
|
||||
final Value<String> senderId;
|
||||
final Value<String> senderName;
|
||||
final Value<String> type;
|
||||
final Value<String> filePath;
|
||||
final Value<DateTime> createdAt;
|
||||
final Value<int> rowid;
|
||||
const MediaItemsCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.senderId = const Value.absent(),
|
||||
this.senderName = const Value.absent(),
|
||||
this.type = const Value.absent(),
|
||||
this.filePath = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
MediaItemsCompanion.insert({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
this.createdAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
senderId = Value(senderId),
|
||||
senderName = Value(senderName),
|
||||
type = Value(type),
|
||||
filePath = Value(filePath);
|
||||
static Insertable<MediaItem> custom({
|
||||
Expression<String>? id,
|
||||
Expression<String>? senderId,
|
||||
Expression<String>? senderName,
|
||||
Expression<String>? type,
|
||||
Expression<String>? filePath,
|
||||
Expression<DateTime>? createdAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (senderId != null) 'sender_id': senderId,
|
||||
if (senderName != null) 'sender_name': senderName,
|
||||
if (type != null) 'type': type,
|
||||
if (filePath != null) 'file_path': filePath,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
MediaItemsCompanion copyWith(
|
||||
{Value<String>? id,
|
||||
Value<String>? senderId,
|
||||
Value<String>? senderName,
|
||||
Value<String>? type,
|
||||
Value<String>? filePath,
|
||||
Value<DateTime>? createdAt,
|
||||
Value<int>? rowid}) {
|
||||
return MediaItemsCompanion(
|
||||
id: id ?? this.id,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
type: type ?? this.type,
|
||||
filePath: filePath ?? this.filePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<String>(id.value);
|
||||
}
|
||||
if (senderId.present) {
|
||||
map['sender_id'] = Variable<String>(senderId.value);
|
||||
}
|
||||
if (senderName.present) {
|
||||
map['sender_name'] = Variable<String>(senderName.value);
|
||||
}
|
||||
if (type.present) {
|
||||
map['type'] = Variable<String>(type.value);
|
||||
}
|
||||
if (filePath.present) {
|
||||
map['file_path'] = Variable<String>(filePath.value);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<DateTime>(createdAt.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('MediaItemsCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('senderId: $senderId, ')
|
||||
..write('senderName: $senderName, ')
|
||||
..write('type: $type, ')
|
||||
..write('filePath: $filePath, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$EphemeralDatabase extends GeneratedDatabase {
|
||||
_$EphemeralDatabase(QueryExecutor e) : super(e);
|
||||
$EphemeralDatabaseManager get managers => $EphemeralDatabaseManager(this);
|
||||
late final $MediaItemsTable mediaItems = $MediaItemsTable(this);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [mediaItems];
|
||||
}
|
||||
|
||||
typedef $$MediaItemsTableCreateCompanionBuilder = MediaItemsCompanion Function({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
Value<DateTime> createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$MediaItemsTableUpdateCompanionBuilder = MediaItemsCompanion Function({
|
||||
Value<String> id,
|
||||
Value<String> senderId,
|
||||
Value<String> senderName,
|
||||
Value<String> type,
|
||||
Value<String> filePath,
|
||||
Value<DateTime> createdAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$MediaItemsTableFilterComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get id => $composableBuilder(
|
||||
column: $table.id, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get senderId => $composableBuilder(
|
||||
column: $table.senderId, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get type => $composableBuilder(
|
||||
column: $table.type, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get filePath => $composableBuilder(
|
||||
column: $table.filePath, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt, builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$MediaItemsTableOrderingComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get id => $composableBuilder(
|
||||
column: $table.id, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get senderId => $composableBuilder(
|
||||
column: $table.senderId, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get type => $composableBuilder(
|
||||
column: $table.type, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get filePath => $composableBuilder(
|
||||
column: $table.filePath, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$MediaItemsTableAnnotationComposer
|
||||
extends Composer<_$EphemeralDatabase, $MediaItemsTable> {
|
||||
$$MediaItemsTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get senderId =>
|
||||
$composableBuilder(column: $table.senderId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get senderName => $composableBuilder(
|
||||
column: $table.senderName, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get type =>
|
||||
$composableBuilder(column: $table.type, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get filePath =>
|
||||
$composableBuilder(column: $table.filePath, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get createdAt =>
|
||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$MediaItemsTableTableManager extends RootTableManager<
|
||||
_$EphemeralDatabase,
|
||||
$MediaItemsTable,
|
||||
MediaItem,
|
||||
$$MediaItemsTableFilterComposer,
|
||||
$$MediaItemsTableOrderingComposer,
|
||||
$$MediaItemsTableAnnotationComposer,
|
||||
$$MediaItemsTableCreateCompanionBuilder,
|
||||
$$MediaItemsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MediaItem,
|
||||
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
|
||||
),
|
||||
MediaItem,
|
||||
PrefetchHooks Function()> {
|
||||
$$MediaItemsTableTableManager(_$EphemeralDatabase db, $MediaItemsTable table)
|
||||
: super(TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$MediaItemsTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$MediaItemsTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$MediaItemsTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback: ({
|
||||
Value<String> id = const Value.absent(),
|
||||
Value<String> senderId = const Value.absent(),
|
||||
Value<String> senderName = const Value.absent(),
|
||||
Value<String> type = const Value.absent(),
|
||||
Value<String> filePath = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
MediaItemsCompanion(
|
||||
id: id,
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
required String id,
|
||||
required String senderId,
|
||||
required String senderName,
|
||||
required String type,
|
||||
required String filePath,
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
MediaItemsCompanion.insert(
|
||||
id: id,
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
createdAt: createdAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
));
|
||||
}
|
||||
|
||||
typedef $$MediaItemsTableProcessedTableManager = ProcessedTableManager<
|
||||
_$EphemeralDatabase,
|
||||
$MediaItemsTable,
|
||||
MediaItem,
|
||||
$$MediaItemsTableFilterComposer,
|
||||
$$MediaItemsTableOrderingComposer,
|
||||
$$MediaItemsTableAnnotationComposer,
|
||||
$$MediaItemsTableCreateCompanionBuilder,
|
||||
$$MediaItemsTableUpdateCompanionBuilder,
|
||||
(
|
||||
MediaItem,
|
||||
BaseReferences<_$EphemeralDatabase, $MediaItemsTable, MediaItem>
|
||||
),
|
||||
MediaItem,
|
||||
PrefetchHooks Function()>;
|
||||
|
||||
class $EphemeralDatabaseManager {
|
||||
final _$EphemeralDatabase _db;
|
||||
$EphemeralDatabaseManager(this._db);
|
||||
$$MediaItemsTableTableManager get mediaItems =>
|
||||
$$MediaItemsTableTableManager(_db, _db.mediaItems);
|
||||
}
|
||||
@@ -1,31 +1,53 @@
|
||||
// lib/game/base_game.dart
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../network/network_manager.dart';
|
||||
import '../model/play_packet.dart';
|
||||
|
||||
/// 모든 미니게임의 부모 클래스
|
||||
/// 모든 게임의 부모 클래스 (자동 패킷 리스닝 기능 탑재)
|
||||
abstract class BaseGame {
|
||||
/// 게임 고유 ID (예: 'quiz', 'bomb') - 패킷 라우팅용
|
||||
String get id;
|
||||
|
||||
/// 게임 이름 (로비 표시용)
|
||||
String get name;
|
||||
|
||||
/// 게임 설명
|
||||
String get description;
|
||||
|
||||
/// [Host] 방장이 보는 게임 화면 빌드
|
||||
Widget buildHostView(BuildContext context);
|
||||
// 네트워크 구독 관리자 (private)
|
||||
StreamSubscription? _internalSubscription;
|
||||
|
||||
/// [Guest] 참가자가 보는 게임 화면 빌드
|
||||
Widget buildGuestView(BuildContext context);
|
||||
/// [Core] 게임 시작 시 자동 호출 (super.onStart() 필수 호출)
|
||||
@mustCallSuper
|
||||
void onStart() {
|
||||
print("[$name] Game Engine Started");
|
||||
|
||||
// 네트워크 스트림 자동 구독
|
||||
_internalSubscription = NetworkManager().messageStream.listen((payload) {
|
||||
// 1. PlayPacket으로 변환 시도 (구조화된 데이터)
|
||||
if (payload.containsKey('type') && payload.containsKey('payload')) {
|
||||
try {
|
||||
// 만약 패킷 타입이 'game'이라면 onGamePacketReceived 호출
|
||||
// (여기서는 단순화를 위해 모든 JSON을 자식에게 넘기되, 자식이 알아서 필터링하게 하거나
|
||||
// PlayPacket 구조를 강제할 수 있습니다. 현재는 하위 호환성을 위해 raw data 전달)
|
||||
onMessageReceived("", payload);
|
||||
} catch (e) {
|
||||
print("Packet Error: $e");
|
||||
}
|
||||
} else {
|
||||
// 레거시 데이터 처리
|
||||
onMessageReceived("", payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 게임이 시작될 때 초기화 로직 (변수 초기화 등)
|
||||
void onStart();
|
||||
/// [Core] 게임 종료 시 자동 호출 (super.onDispose() 필수 호출)
|
||||
@mustCallSuper
|
||||
void onDispose() {
|
||||
print("[$name] Game Engine Disposed");
|
||||
_internalSubscription?.cancel();
|
||||
}
|
||||
|
||||
/// 네트워크 메시지 수신 처리
|
||||
/// [senderId]: 보낸 사람 ID
|
||||
/// [payload]: 수신된 데이터 (JSON)
|
||||
/// [Abstract] 자식 클래스가 구현해야 할 데이터 처리 메서드
|
||||
/// Core가 메시지를 받으면 이 함수를 실행시켜 줍니다.
|
||||
void onMessageReceived(String senderId, Map<String, dynamic> payload);
|
||||
|
||||
/// 게임 종료 및 메모리 정리
|
||||
void onDispose();
|
||||
// UI 빌더
|
||||
Widget buildHostView(BuildContext context);
|
||||
Widget buildGuestView(BuildContext context);
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 패킷의 종류 (라우팅 기준)
|
||||
enum PacketType {
|
||||
system, // 시스템 (레디, 시작, 종료, 핸드셰이크 등)
|
||||
chat, // 채팅 (GlobalChatManager로 전달)
|
||||
game, // 게임 로직 (GameController로 전달)
|
||||
media,
|
||||
unknown
|
||||
}
|
||||
|
||||
class PlayPacket {
|
||||
final PacketType type;
|
||||
final String senderId; // 보낸 사람 ID
|
||||
final dynamic payload; // 실제 데이터 (Map, List, String 등)
|
||||
final int timestamp;
|
||||
|
||||
PlayPacket({
|
||||
required this.type,
|
||||
required this.senderId,
|
||||
required this.payload,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
// JSON -> 객체
|
||||
factory PlayPacket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayPacket(
|
||||
type: _parseType(json['type']),
|
||||
senderId: json['senderId'] ?? 'unknown',
|
||||
payload: json['payload'],
|
||||
timestamp: json['timestamp'] ?? DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
// 객체 -> JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name, // enum을 문자열로 ('chat', 'game'...)
|
||||
'senderId': senderId,
|
||||
'payload': payload,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
static PacketType _parseType(String? typeStr) {
|
||||
for (var t in PacketType.values) {
|
||||
if (t.name == typeStr) return t;
|
||||
}
|
||||
// 호환성: 기존 레거시 메시지(PING, ANSWER_SUBMIT 등)는 'unknown'이나 별도 처리
|
||||
return PacketType.unknown;
|
||||
}
|
||||
}
|
||||
@@ -3,51 +3,54 @@ import 'package:equatable/equatable.dart';
|
||||
class UserInfo extends Equatable {
|
||||
final String id;
|
||||
final String nickname;
|
||||
final int avatarIndex; // 프로필 이미지 대신 사용할 아바타 번호 (0~9 등)
|
||||
final int colorValue; // 유저 고유 컬러 (ARGB int)
|
||||
final int avatarIndex;
|
||||
final int colorValue;
|
||||
final bool isReady; // [추가] 준비 상태
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarIndex = 0,
|
||||
this.colorValue = 0xFF2196F3, // 기본값 Blue
|
||||
this.colorValue = 0xFF2196F3,
|
||||
this.isReady = false, // 기본값 false
|
||||
});
|
||||
|
||||
/// JSON -> Object 변환 (네트워크 수신 시)
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] as String,
|
||||
nickname: json['nickname'] as String,
|
||||
avatarIndex: json['avatarIndex'] as int? ?? 0,
|
||||
colorValue: json['colorValue'] as int? ?? 0xFF2196F3,
|
||||
isReady: json['isReady'] as bool? ?? false, // JSON 파싱 추가
|
||||
);
|
||||
}
|
||||
|
||||
/// Object -> JSON 변환 (네트워크 전송 시)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'nickname': nickname,
|
||||
'avatarIndex': avatarIndex,
|
||||
'colorValue': colorValue,
|
||||
'isReady': isReady, // JSON 변환 추가
|
||||
};
|
||||
}
|
||||
|
||||
/// 복사본 생성 (불변 객체 수정용)
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? nickname,
|
||||
int? avatarIndex,
|
||||
int? colorValue,
|
||||
bool? isReady, // copyWith 추가
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
nickname: nickname ?? this.nickname,
|
||||
avatarIndex: avatarIndex ?? this.avatarIndex,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
isReady: isReady ?? this.isReady,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue];
|
||||
List<Object?> get props => [id, nickname, avatarIndex, colorValue, isReady];
|
||||
}
|
||||
@@ -3,121 +3,180 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bonsoir/bonsoir.dart'; // mDNS 패키지
|
||||
import 'package:bonsoir/bonsoir.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../model/user_info.dart';
|
||||
import '../model/play_packet.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart'; // [New] 미디어 매니저
|
||||
|
||||
/// 현재 네트워크 상태 (역할)
|
||||
enum NetworkRole { none, host, guest }
|
||||
|
||||
/// P2P 네트워크 통신을 담당하는 싱글톤 매니저
|
||||
class NetworkManager extends ChangeNotifier {
|
||||
// ------------------------------------------------------------------------
|
||||
// 1. Singleton & Initialization
|
||||
// ------------------------------------------------------------------------
|
||||
class NetworkManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static final NetworkManager _instance = NetworkManager._internal();
|
||||
factory NetworkManager() => _instance;
|
||||
NetworkManager._internal();
|
||||
|
||||
/// 내 정보 (앱 시작 시 initialize 호출 필수)
|
||||
late UserInfo me;
|
||||
|
||||
/// 현재 내 역할
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
/// 초기화 메서드 (닉네임 설정 및 ID 생성)
|
||||
void initialize({required String nickname}) {
|
||||
// 8자리 랜덤 ID 생성
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
// 랜덤 컬러 (간단하게 해시코드로 생성 예시)
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
|
||||
me = UserInfo(
|
||||
id: uuid,
|
||||
nickname: nickname,
|
||||
colorValue: randomColor,
|
||||
);
|
||||
print('[Network] Initialized User: ${me.nickname} (${me.id})');
|
||||
NetworkManager._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 2. Variables & Streams
|
||||
// 상태 변수
|
||||
// ------------------------------------------------------------------------
|
||||
late UserInfo me;
|
||||
NetworkRole role = NetworkRole.none;
|
||||
|
||||
String? hostIp;
|
||||
int? hostPort;
|
||||
|
||||
ServerSocket? _serverSocket;
|
||||
Socket? _clientSocket;
|
||||
|
||||
// 소켓
|
||||
ServerSocket? _serverSocket; // (Host용)
|
||||
Socket? _clientSocket; // (Guest용)
|
||||
final List<Socket> _connectedGuests = []; // (Host가 관리하는 게스트 목록)
|
||||
// 소켓과 유저 정보를 1:1 매핑
|
||||
final Map<Socket, UserInfo?> _connectedGuests = {};
|
||||
|
||||
// UI 표시용 게스트 명단
|
||||
final List<UserInfo> guestList = [];
|
||||
|
||||
// mDNS (방 찾기/만들기)
|
||||
BonsoirService? _bonsoirService;
|
||||
BonsoirBroadcast? _bonsoirBroadcast;
|
||||
BonsoirDiscovery? _bonsoirDiscovery;
|
||||
|
||||
// 수신된 데이터를 앱(GameManager)으로 전달하는 스트림
|
||||
// 게임 데이터 스트림
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 3. Host Logic (방장)
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 방 만들기
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
role = NetworkRole.host;
|
||||
// 로그 스트림
|
||||
final _logController = StreamController<String>.broadcast();
|
||||
Stream<String> get logStream => _logController.stream;
|
||||
|
||||
try {
|
||||
// A. TCP 서버 소켓 오픈 (Port 0 = 시스템 자동 할당)
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
print('[Host] Server opened on port: $port');
|
||||
// 하트비트 & 재접속
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _disconnectWaitTimer;
|
||||
DateTime? _lastPongTime;
|
||||
bool _isReconnecting = false;
|
||||
|
||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
String myIp = '127.0.0.1';
|
||||
try {
|
||||
// 보통 wlan0 혹은 en0가 와이파이 인터페이스
|
||||
myIp = interfaces.firstWhere((i) => i.name != 'lo').addresses.first.address;
|
||||
} catch (e) {
|
||||
print('IP search failed: $e');
|
||||
static const int HEARTBEAT_INTERVAL_SEC = 3;
|
||||
static const int TIMEOUT_SEC = 10;
|
||||
static const int RECONNECT_WAIT_SEC = 5;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 초기화
|
||||
// ------------------------------------------------------------------------
|
||||
void initialize({required String nickname}) {
|
||||
final uuid = const Uuid().v4().substring(0, 8);
|
||||
final randomColor = 0xFF000000 | (nickname.hashCode & 0xFFFFFF);
|
||||
me = UserInfo(id: uuid, nickname: nickname, colorValue: randomColor);
|
||||
_log("초기화 완료: ${me.nickname}");
|
||||
}
|
||||
|
||||
// B. 게스트 접속 대기
|
||||
void _log(String msg) {
|
||||
final timestamp = DateTime.now().toIso8601String().split('T').last.substring(0, 8);
|
||||
print("[$timestamp] $msg");
|
||||
_logController.add("[$timestamp] $msg");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (role == NetworkRole.guest && _clientSocket == null && hostIp != null) {
|
||||
_attemptReconnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 레디 시스템
|
||||
// ------------------------------------------------------------------------
|
||||
void toggleReady() {
|
||||
me = me.copyWith(isReady: !me.isReady);
|
||||
notifyListeners();
|
||||
|
||||
final payload = {
|
||||
'type': 'TOGGLE_READY',
|
||||
'userId': me.id,
|
||||
'isReady': me.isReady,
|
||||
};
|
||||
sendMessage(payload);
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkAllReadyAndStart() {
|
||||
if (guestList.isEmpty) return;
|
||||
if (!me.isReady) return;
|
||||
|
||||
bool allGuestsReady = guestList.every((u) => u.isReady);
|
||||
|
||||
if (allGuestsReady) {
|
||||
_log("🚀 전원 준비 완료! 3초 후 게임 시작...");
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
final startPayload = {'type': 'GAME_START', 'gameId': 'quiz_ox'};
|
||||
sendMessage(startPayload);
|
||||
_messageController.add(startPayload);
|
||||
_resetAllReadyState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAllReadyState() {
|
||||
me = me.copyWith(isReady: false);
|
||||
for (int i = 0; i < guestList.length; i++) {
|
||||
guestList[i] = guestList[i].copyWith(isReady: false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Host Logic
|
||||
// ------------------------------------------------------------------------
|
||||
Future<void> startHosting(String roomName) async {
|
||||
stopNetwork(force: true);
|
||||
role = NetworkRole.host;
|
||||
|
||||
try {
|
||||
_serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
int port = _serverSocket!.port;
|
||||
this.hostPort = port;
|
||||
|
||||
String? myIp = await _getWifiIp();
|
||||
this.hostIp = myIp ?? '127.0.0.1';
|
||||
_log("✅ 방 생성: $hostIp : $port");
|
||||
|
||||
_serverSocket!.listen((Socket client) {
|
||||
_handleNewGuest(client);
|
||||
});
|
||||
|
||||
// C. mDNS로 방 광고 (Broadcast)
|
||||
// 서비스 타입은 고유해야 함 (_playwith._tcp)
|
||||
// 이름 포맷: "방이름#호스트ID" (중복 방지 및 식별용)
|
||||
_bonsoirService = BonsoirService(
|
||||
name: '$roomName#${me.id}',
|
||||
name: '$roomName#${me.id}',
|
||||
type: '_playwith._tcp',
|
||||
port: port,
|
||||
attributes: {'ip': myIp},
|
||||
attributes: {'ip': hostIp!},
|
||||
);
|
||||
|
||||
_bonsoirBroadcast = BonsoirBroadcast(service: _bonsoirService!);
|
||||
await _bonsoirBroadcast!.ready;
|
||||
await _bonsoirBroadcast!.start();
|
||||
|
||||
print('[Host] Start advertising room: $roomName');
|
||||
// [NEW] 미디어 DB 초기화 (Host)
|
||||
await MediaManager().initialize(roomName);
|
||||
|
||||
_startHeartbeat();
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Host] Error starting host: $e');
|
||||
stopNetwork();
|
||||
_log("❌ 방 생성 실패: $e");
|
||||
stopNetwork(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로운 게스트가 접속했을 때
|
||||
void _handleNewGuest(Socket client) {
|
||||
print('[Host] New guest connected: ${client.remoteAddress.address}');
|
||||
_connectedGuests.add(client);
|
||||
|
||||
// 데이터 수신 리스너 부착
|
||||
_log("🎉 연결됨: ${client.remoteAddress.address}");
|
||||
_connectedGuests[client] = null;
|
||||
|
||||
client.listen(
|
||||
(Uint8List data) => _onDataReceived(client, data),
|
||||
onError: (e) => _removeGuest(client),
|
||||
@@ -126,163 +185,289 @@ final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
}
|
||||
|
||||
void _removeGuest(Socket client) {
|
||||
print('[Host] Guest disconnected');
|
||||
final UserInfo? user = _connectedGuests[client];
|
||||
if (user != null) {
|
||||
_log("👋 퇴장: ${user.nickname}");
|
||||
guestList.removeWhere((u) => u.id == user.id);
|
||||
}
|
||||
_connectedGuests.remove(client);
|
||||
client.close();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 4. Guest Logic (참가자)
|
||||
// Guest Logic
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 주변 방 찾기 (mDNS Discovery)
|
||||
Stream<List<BonsoirService>> discoverRooms() {
|
||||
// 리스트를 계속 갱신해서 내보내기 위한 컨트롤러
|
||||
final controller = StreamController<List<BonsoirService>>();
|
||||
final List<BonsoirService> foundServices = [];
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
|
||||
_bonsoirDiscovery!.ready.then((_) {
|
||||
_bonsoirDiscovery!.start();
|
||||
|
||||
_bonsoirDiscovery!.eventStream!.listen((event) {
|
||||
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
|
||||
if (event.service != null) {
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
} else if (event.type == BonsoirDiscoveryEventType.discoveryServiceLost) {
|
||||
if (event.service != null) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
Future(() async {
|
||||
try {
|
||||
_bonsoirDiscovery = BonsoirDiscovery(type: '_playwith._tcp');
|
||||
await _bonsoirDiscovery!.start();
|
||||
|
||||
if (_bonsoirDiscovery?.eventStream != null) {
|
||||
_bonsoirDiscovery!.eventStream!.listen((dynamic event) {
|
||||
final String type = event.type.toString();
|
||||
if (event.service == null) return;
|
||||
|
||||
if (type.contains('Found')) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
foundServices.add(event.service!);
|
||||
controller.add(List.from(foundServices));
|
||||
} else if (type.contains('Lost')) {
|
||||
foundServices.removeWhere((s) => s.name == event.service!.name);
|
||||
controller.add(List.from(foundServices));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
_log("스캔 실패: $e");
|
||||
}
|
||||
});
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
/// 방 접속하기
|
||||
Future<void> joinRoom(String ip, int port) async {
|
||||
stopNetwork(); // 기존 연결 정리
|
||||
if (role != NetworkRole.guest) stopNetwork(force: true);
|
||||
role = NetworkRole.guest;
|
||||
hostIp = ip;
|
||||
hostPort = port;
|
||||
|
||||
try {
|
||||
print('[Guest] Connecting to $ip:$port...');
|
||||
_clientSocket = await Socket.connect(ip, port);
|
||||
print('[Guest] Connected!');
|
||||
_log("🚀 접속 시도: $ip:$port");
|
||||
_clientSocket = await Socket.connect(ip, port, timeout: const Duration(seconds: 5));
|
||||
_log("✅ 접속 성공!");
|
||||
|
||||
// 접속 성공 시 즉시 내 정보 전송 (Handshake)
|
||||
sendMessage({
|
||||
'type': 'HANDSHAKE',
|
||||
'senderId': me.id,
|
||||
'payload': me.toJson(),
|
||||
});
|
||||
sendMessage({'type': 'HANDSHAKE', 'payload': me.toJson()});
|
||||
|
||||
// [NEW] 미디어 DB 초기화 (Guest는 임시 ID 사용)
|
||||
await MediaManager().initialize("guest_${ip.replaceAll('.', '_')}");
|
||||
|
||||
_lastPongTime = DateTime.now();
|
||||
_startHeartbeat();
|
||||
_cancelDisconnectTimer();
|
||||
|
||||
// 데이터 수신 리스너
|
||||
_clientSocket!.listen(
|
||||
(Uint8List data) => _onDataReceived(_clientSocket!, data),
|
||||
onError: (e) {
|
||||
print('[Guest] Connection error: $e');
|
||||
stopNetwork();
|
||||
},
|
||||
onDone: () {
|
||||
print('[Guest] Disconnected by host');
|
||||
stopNetwork();
|
||||
},
|
||||
onError: (e) => _handleConnectionLost(e),
|
||||
onDone: () => _handleConnectionLost("Socket Closed"),
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
} catch (e) {
|
||||
print('[Guest] Failed to join: $e');
|
||||
role = NetworkRole.none;
|
||||
notifyListeners();
|
||||
rethrow; // UI에서 에러 처리할 수 있게 던짐
|
||||
_log("❌ 접속 실패: $e");
|
||||
if (!_isReconnecting) stopNetwork(force: true);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 5. Common Logic (데이터 송수신)
|
||||
// 데이터 송수신 & 라우팅 (핵심)
|
||||
// ------------------------------------------------------------------------
|
||||
void sendPacket(PlayPacket packet) {
|
||||
sendMessage(packet.toJson());
|
||||
}
|
||||
|
||||
/// 메시지 전송
|
||||
void sendMessage(Map<String, dynamic> messageMap) {
|
||||
try {
|
||||
// JSON 변환
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
// 패킷 경계 처리를 위해 끝에 줄바꿈(\n) 추가 (가장 간단한 delimiter)
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
if (role == NetworkRole.guest && _clientSocket == null) return;
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
// Host는 모든 Guest에게 브로드캐스트
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else if (role == NetworkRole.guest) {
|
||||
// Guest는 Host에게 전송
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Network] Send Error: $e');
|
||||
}
|
||||
final jsonString = jsonEncode(messageMap);
|
||||
// 로그 필터링
|
||||
if (messageMap['type'] != 'PING' && messageMap['type'] != 'PONG') {
|
||||
if (messageMap['type'] == 'chat') {
|
||||
_log("📤 전송: [CHAT]");
|
||||
} else if (messageMap['type'] == 'media') {
|
||||
_log("📤 전송: [MEDIA]");
|
||||
} else {
|
||||
_log("📤 전송: $jsonString");
|
||||
}
|
||||
}
|
||||
|
||||
final List<int> data = utf8.encode('$jsonString\n');
|
||||
if (role == NetworkRole.host) {
|
||||
for (var socket in _connectedGuests.keys) {
|
||||
socket.add(data);
|
||||
}
|
||||
} else {
|
||||
_clientSocket?.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 데이터 수신 처리
|
||||
void _onDataReceived(Socket socket, Uint8List data) {
|
||||
// 들어온 데이터를 String으로 변환
|
||||
final String rawString = utf8.decode(data);
|
||||
|
||||
// TCP 패킷이 뭉쳐서 올 수 있으므로 \n으로 쪼갬
|
||||
final List<String> splitMessages = rawString.split('\n');
|
||||
|
||||
|
||||
for (var msg in splitMessages) {
|
||||
if (msg.trim().isEmpty) continue;
|
||||
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> parsedData = jsonDecode(msg);
|
||||
|
||||
// 1. 앱 로직으로 전달
|
||||
_messageController.add(parsedData);
|
||||
final Map<String, dynamic> jsonMap = jsonDecode(msg);
|
||||
|
||||
// 2. (옵션) Host라면, 받은 메시지를 다른 Guest들에게도 전달(Relay)해야 할 수 있음
|
||||
// 게임 로직에 따라 다르지만 보통 Host가 중계자 역할을 함
|
||||
// if (role == NetworkRole.host) { sendMessage(parsedData); }
|
||||
// 1. 시스템 메시지 (Ping/Pong)
|
||||
if (jsonMap['type'] == 'PING') {
|
||||
sendMessage({'type': 'PONG'});
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
if (jsonMap['type'] == 'PONG') {
|
||||
_lastPongTime = DateTime.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 핸드셰이크
|
||||
if (jsonMap['type'] == 'HANDSHAKE') {
|
||||
final guestInfo = UserInfo.fromJson(jsonMap['payload']);
|
||||
_connectedGuests[socket] = guestInfo;
|
||||
guestList.removeWhere((u) => u.id == guestInfo.id);
|
||||
guestList.add(guestInfo);
|
||||
notifyListeners();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 레디 토글
|
||||
if (jsonMap['type'] == 'TOGGLE_READY') {
|
||||
final String userId = jsonMap['userId'];
|
||||
final bool isReady = jsonMap['isReady'];
|
||||
|
||||
final index = guestList.indexWhere((u) => u.id == userId);
|
||||
if (index != -1) {
|
||||
guestList[index] = guestList[index].copyWith(isReady: isReady);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (role == NetworkRole.host) {
|
||||
sendMessage(jsonMap);
|
||||
_checkAllReadyAndStart();
|
||||
}
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 게임 시작
|
||||
if (jsonMap['type'] == 'GAME_START') {
|
||||
_resetAllReadyState();
|
||||
_messageController.add(jsonMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 패킷 라우팅 (Chat, Media, Game)
|
||||
if (jsonMap.containsKey('payload') && jsonMap.containsKey('senderId')) {
|
||||
final packet = PlayPacket.fromJson(jsonMap);
|
||||
|
||||
// [라우팅] 채팅 -> GlobalChatManager
|
||||
if (packet.type == PacketType.chat) {
|
||||
GlobalChatManager().onPacketReceived(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
// [라우팅] 미디어 -> MediaManager
|
||||
if (packet.type == PacketType.media) {
|
||||
MediaManager().onMediaReceived(packet);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 그 외 게임 데이터
|
||||
_messageController.add(jsonMap);
|
||||
|
||||
} catch (e) {
|
||||
print('[Network] Parse Error: $e\nMessage: $msg');
|
||||
_log("파싱 에러: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 6. Cleanup
|
||||
// 연결 관리
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 네트워크 종료 및 리소스 정리
|
||||
void stopNetwork() {
|
||||
print('[Network] Stopping network...');
|
||||
void _handleConnectionLost(dynamic reason) {
|
||||
if (role != NetworkRole.guest) return;
|
||||
_log("⚠️ 연결 끊김: $reason");
|
||||
|
||||
// mDNS 중지
|
||||
_clientSocket?.destroy();
|
||||
_clientSocket = null;
|
||||
|
||||
if (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) return;
|
||||
|
||||
_disconnectWaitTimer = Timer(const Duration(seconds: RECONNECT_WAIT_SEC), () {
|
||||
_log("💀 복구 실패. 종료.");
|
||||
stopNetwork(force: true);
|
||||
});
|
||||
_attemptReconnection();
|
||||
}
|
||||
|
||||
Future<void> _attemptReconnection() async {
|
||||
if (hostIp == null || hostPort == null) return;
|
||||
_isReconnecting = true;
|
||||
while (_disconnectWaitTimer != null && _disconnectWaitTimer!.isActive) {
|
||||
try {
|
||||
await joinRoom(hostIp!, hostPort!);
|
||||
_log("✅ 재접속 성공!");
|
||||
_isReconnecting = false;
|
||||
return;
|
||||
} catch (e) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
_isReconnecting = false;
|
||||
}
|
||||
|
||||
void _cancelDisconnectTimer() {
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: HEARTBEAT_INTERVAL_SEC), (timer) {
|
||||
try { sendMessage({'type': 'PING'}); } catch (_) {}
|
||||
if (role == NetworkRole.guest && _lastPongTime != null) {
|
||||
if (DateTime.now().difference(_lastPongTime!).inSeconds > TIMEOUT_SEC) {
|
||||
_handleConnectionLost("Heartbeat Timeout");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _getWifiIp() async {
|
||||
try {
|
||||
for (var interface in await NetworkInterface.list()) {
|
||||
if (interface.name.contains('wlan') || interface.name.contains('en') || interface.name.contains('ap')) {
|
||||
for (var addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {/**/}
|
||||
return null;
|
||||
}
|
||||
|
||||
void stopNetwork({bool force = false}) {
|
||||
if (!force && _disconnectWaitTimer != null) return;
|
||||
|
||||
_log("🛑 종료");
|
||||
// [NEW] 미디어 DB 정리
|
||||
MediaManager().cleanup();
|
||||
|
||||
_heartbeatTimer?.cancel();
|
||||
_disconnectWaitTimer?.cancel();
|
||||
_disconnectWaitTimer = null;
|
||||
_bonsoirBroadcast?.stop();
|
||||
_bonsoirDiscovery?.stop();
|
||||
|
||||
// 소켓 닫기
|
||||
_serverSocket?.close();
|
||||
_clientSocket?.close();
|
||||
|
||||
for (var socket in _connectedGuests) {
|
||||
socket.close();
|
||||
}
|
||||
for (var s in _connectedGuests.keys) s.close();
|
||||
_connectedGuests.clear();
|
||||
|
||||
guestList.clear();
|
||||
role = NetworkRole.none;
|
||||
_serverSocket = null;
|
||||
_clientSocket = null;
|
||||
|
||||
if (force) { hostIp = null; hostPort = null; }
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
// lib/playwith_core.dart
|
||||
|
||||
library playwith_core;
|
||||
|
||||
export 'game/base_game.dart';
|
||||
export 'network/network_manager.dart';
|
||||
export 'model/user_info.dart';
|
||||
export 'model/user_info.dart';
|
||||
export 'model/play_packet.dart';
|
||||
export 'utils/sound_manager.dart';
|
||||
export 'manager/global_chat_manager.dart';
|
||||
export 'widgets/game_chat_overlay.dart';
|
||||
|
||||
// [추가] DB 관련 (Drift가 생성한 데이터 클래스들도 쓰기 위해)
|
||||
export 'database/ephemeral_database.dart';
|
||||
// Drift의 기본 타입(Value 등)을 쓰려면 아래 줄도 필요할 수 있음 (선택)
|
||||
export 'package:drift/drift.dart' show Value;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../manager/global_chat_manager.dart';
|
||||
import '../manager/media_manager.dart'; // 미디어 매니저
|
||||
import '../database/ephemeral_database.dart'; // DB 모델
|
||||
|
||||
class GameChatOverlay extends StatefulWidget {
|
||||
const GameChatOverlay({super.key});
|
||||
|
||||
@override
|
||||
State<GameChatOverlay> createState() => _GameChatOverlayState();
|
||||
}
|
||||
|
||||
class _GameChatOverlayState extends State<GameChatOverlay> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
// 높이 조정: 미디어 갤러리가 보일 공간 확보 (펼쳤을 때)
|
||||
height: _isExpanded ? 500 : 60,
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2)],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. 상단 핸들 (접기/펼치기)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
_isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 펼쳤을 때만 보이는 영역
|
||||
if (_isExpanded) ...[
|
||||
|
||||
// 2. 미디어 갤러리 (가로 스크롤)
|
||||
// DB의 변경사항을 실시간으로 감지(Stream)하여 보여줌
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: StreamBuilder<List<MediaItem>>(
|
||||
stream: MediaManager().galleryStream,
|
||||
builder: (context, snapshot) {
|
||||
final mediaList = snapshot.data ?? [];
|
||||
|
||||
if (mediaList.isEmpty) {
|
||||
return const Center(child: Text("공유된 미디어가 없습니다.", style: TextStyle(color: Colors.white54, fontSize: 12)));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: mediaList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = mediaList[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, item),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(item.filePath),
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_,__,___) => Container(
|
||||
width: 100, height: 100, color: Colors.grey,
|
||||
child: const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white24),
|
||||
|
||||
// 3. 채팅 리스트
|
||||
Expanded(
|
||||
child: StreamBuilder<List<ChatMessage>>(
|
||||
stream: GlobalChatManager().messageStream,
|
||||
builder: (context, snapshot) {
|
||||
final messages = snapshot.data ?? [];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
"${msg.senderName}: ${msg.text}",
|
||||
style: TextStyle(
|
||||
color: msg.isMe ? Colors.yellow : Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 4. 입력창 (+ 미디어 버튼)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// [추가] 이미지 전송 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate, color: Colors.blueAccent),
|
||||
onPressed: _pickAndSendImage,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "채팅 입력...",
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: _sendMessage,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, color: Colors.blue),
|
||||
onPressed: () => _sendMessage(_textController.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage(String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
GlobalChatManager().sendMessage(text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
// [이미지 선택 및 전송 로직]
|
||||
Future<void> _pickAndSendImage() async {
|
||||
final picker = ImagePicker();
|
||||
// 갤러리에서 이미지 선택 (압축 옵션 추가 권장)
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 50, // 전송 속도를 위해 품질 낮춤
|
||||
maxWidth: 800,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
// MediaManager를 통해 전송
|
||||
await MediaManager().sendMedia(
|
||||
filePath: image.path,
|
||||
type: 'IMAGE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [이미지 크게 보기 팝업]
|
||||
void _showFullImage(BuildContext context, MediaItem item) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
child: Image.file(File(item.filePath)),
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 30),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: Colors.black54,
|
||||
child: Text("보낸 사람: ${item.senderName}", style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,21 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# 네트워크 디스커버리 (mDNS)
|
||||
bonsoir: ^2.0.0
|
||||
# 고유 ID 생성
|
||||
# 기존 ^2.0.0 등을 지우고 최신 버전으로 변경
|
||||
bonsoir: ^6.0.1
|
||||
uuid: ^4.0.0
|
||||
# 데이터 비교 및 불변성 (선택사항이지만 추천)
|
||||
equatable: ^2.0.5
|
||||
equatable: ^2.0.5
|
||||
permission_handler: ^11.0.0
|
||||
# [DB]
|
||||
drift: ^2.13.0
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
path_provider: ^2.1.1
|
||||
path: ^1.8.3
|
||||
|
||||
# [파일 피커]
|
||||
image_picker: ^1.0.4
|
||||
file_picker: ^6.1.1
|
||||
|
||||
dev_dependencies:
|
||||
drift_dev: ^2.13.0
|
||||
build_runner: ^2.4.6
|
||||
Reference in New Issue
Block a user