diff --git a/.gitea/workflows/pull_request.yaml b/.gitea/workflows/pull_request.yaml new file mode 100644 index 0000000..43d36d2 --- /dev/null +++ b/.gitea/workflows/pull_request.yaml @@ -0,0 +1,57 @@ +name: Pull Request Pipeline + +on: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install jq + run: | + apt-get update + apt-get install -y jq + + - name: Install Flutter (wget) + run: | + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.38.2-stable.tar.xz + tar xf flutter_linux_3.38.2-stable.tar.xz + # Set Git safe directory for Flutter path + git config --global --add safe.directory "$(pwd)/flutter" + # Set Flutter path + echo "$(pwd)/flutter/bin" >> $GITHUB_PATH + + - name: Get dependencies + run: flutter pub get + + - name: Analyze Formatting + run: flutter analyze lib test + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + apt-get install -y jq + + - name: Install Flutter (wget) + run: | + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.38.2-stable.tar.xz + tar xf flutter_linux_3.38.2-stable.tar.xz + # Set Git safe directory for Flutter path + git config --global --add safe.directory "$(pwd)/flutter" + # Set Flutter path + echo "$(pwd)/flutter/bin" >> $GITHUB_PATH + + - name: Get dependencies + run: flutter pub get + + - name: Run tests + run: flutter test \ No newline at end of file diff --git a/.gitea/workflows/push.yaml b/.gitea/workflows/push.yaml new file mode 100644 index 0000000..700e96b --- /dev/null +++ b/.gitea/workflows/push.yaml @@ -0,0 +1,50 @@ +name: Push Pipeline + +on: + push: + branches: + - "development" + - "main" + +jobs: + format: + runs-on: ubuntu-latest + if: false # Needs bot user + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + apt-get install -y jq + + - name: Install Flutter (wget) + run: | + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.38.2-stable.tar.xz + tar xf flutter_linux_3.38.2-stable.tar.xz + # Set Git safe directory for Flutter path + git config --global --add safe.directory "$(pwd)/flutter" + # Set Flutter path + echo "$(pwd)/flutter/bin" >> $GITHUB_PATH + + - name: Get & upgrade dependencies + run: | + flutter pub get + flutter pub upgrade --major-versions + + - name: Auto-format + run: | + dart format lib + dart fix --apply lib + + # Needs credentials, push access and the right files need to be staged + - name: Commit Changes + run: | + git config --global user.name "Gitea Actions" + git config --global user.email "actions@gitea.com" + git status + git add lib/ + git status + git commit -m "Actions: Auto-formatting [skip ci]" + git push diff --git a/lib/core/enums.dart b/lib/core/enums.dart new file mode 100644 index 0000000..320eaf7 --- /dev/null +++ b/lib/core/enums.dart @@ -0,0 +1,2 @@ +/// Button types used for styling the [CustomWidthButton] +enum ButtonType { primary, secondary, tertiary } diff --git a/lib/data/dao/game_dao.dart b/lib/data/dao/game_dao.dart index fc931ad..94d010c 100644 --- a/lib/data/dao/game_dao.dart +++ b/lib/data/dao/game_dao.dart @@ -15,7 +15,11 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { Future> getAllGames() async { final query = select(gameTable); final result = await query.get(); - return result.map((row) => Game(id: row.id, name: row.name)).toList(); + return result + .map( + (row) => Game(id: row.id, name: row.name, createdAt: row.createdAt), + ) + .toList(); } /// Retrieves a [Game] by its [gameId]. @@ -38,6 +42,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { players: players, group: group, winner: result.winnerId, + createdAt: result.createdAt, ); } @@ -58,6 +63,7 @@ class GameDao extends DatabaseAccessor with _$GameDaoMixin { id: game.id, name: game.name, winnerId: game.winner, + createdAt: game.createdAt, ), mode: InsertMode.insertOrReplace, ); diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index 8eb3a1a..3378948 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -19,7 +19,12 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { final members = await db.playerGroupDao.getPlayersOfGroupById( groupId: groupData.id, ); - return Group(id: groupData.id, name: groupData.name, members: members); + return Group( + id: groupData.id, + name: groupData.name, + members: members, + createdAt: groupData.createdAt, + ); }), ); } @@ -33,7 +38,12 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { groupId: groupId, ); - return Group(id: result.id, name: result.name, members: members); + return Group( + id: result.id, + name: result.name, + members: members, + createdAt: result.createdAt, + ); } /// Adds a new group with the given [id] and [name] to the database. @@ -41,9 +51,13 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { Future addGroup({required Group group}) async { if (!await groupExists(groupId: group.id)) { await db.transaction(() async { - await into( - groupTable, - ).insert(GroupTableCompanion.insert(id: group.id, name: group.name)); + await into(groupTable).insert( + GroupTableCompanion.insert( + id: group.id, + name: group.name, + createdAt: group.createdAt, + ), + ); await db.batch( (b) => b.insertAll( db.playerGroupTable, @@ -60,8 +74,8 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { await Future.wait( group.members.map((player) => db.playerDao.addPlayer(player: player)), ); - return true; }); + return true; } return false; } diff --git a/lib/data/dao/player_dao.dart b/lib/data/dao/player_dao.dart index 591634c..36f9305 100644 --- a/lib/data/dao/player_dao.dart +++ b/lib/data/dao/player_dao.dart @@ -13,14 +13,22 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { Future> getAllPlayers() async { final query = select(playerTable); final result = await query.get(); - return result.map((row) => Player(id: row.id, name: row.name)).toList(); + return result + .map( + (row) => Player(id: row.id, name: row.name, createdAt: row.createdAt), + ) + .toList(); } /// Retrieves a [Player] by their [id]. Future getPlayerById({required String playerId}) async { final query = select(playerTable)..where((p) => p.id.equals(playerId)); final result = await query.getSingle(); - return Player(id: result.id, name: result.name); + return Player( + id: result.id, + name: result.name, + createdAt: result.createdAt, + ); } /// Adds a new [player] to the database. @@ -28,9 +36,13 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { /// the new one. Future addPlayer({required Player player}) async { if (!await playerExists(playerId: player.id)) { - await into( - playerTable, - ).insert(PlayerTableCompanion.insert(id: player.id, name: player.name)); + await into(playerTable).insert( + PlayerTableCompanion.insert( + id: player.id, + name: player.name, + createdAt: player.createdAt, + ), + ); return true; } return false; diff --git a/lib/data/db/database.g.dart b/lib/data/db/database.g.dart index 03b7a10..3f10169 100644 --- a/lib/data/db/database.g.dart +++ b/lib/data/db/database.g.dart @@ -27,8 +27,19 @@ class $PlayerTableTable extends PlayerTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override - List get $columns => [id, name]; + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, name, createdAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -54,6 +65,14 @@ class $PlayerTableTable extends PlayerTable } else if (isInserting) { context.missing(_nameMeta); } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } return context; } @@ -71,6 +90,10 @@ class $PlayerTableTable extends PlayerTable DriftSqlType.string, data['${effectivePrefix}name'], )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, ); } @@ -83,17 +106,27 @@ class $PlayerTableTable extends PlayerTable class PlayerTableData extends DataClass implements Insertable { final String id; final String name; - const PlayerTableData({required this.id, required this.name}); + final DateTime createdAt; + const PlayerTableData({ + required this.id, + required this.name, + required this.createdAt, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['name'] = Variable(name); + map['created_at'] = Variable(createdAt); return map; } PlayerTableCompanion toCompanion(bool nullToAbsent) { - return PlayerTableCompanion(id: Value(id), name: Value(name)); + return PlayerTableCompanion( + id: Value(id), + name: Value(name), + createdAt: Value(createdAt), + ); } factory PlayerTableData.fromJson( @@ -104,6 +137,7 @@ class PlayerTableData extends DataClass implements Insertable { return PlayerTableData( id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), + createdAt: serializer.fromJson(json['createdAt']), ); } @override @@ -112,15 +146,21 @@ class PlayerTableData extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'name': serializer.toJson(name), + 'createdAt': serializer.toJson(createdAt), }; } - PlayerTableData copyWith({String? id, String? name}) => - PlayerTableData(id: id ?? this.id, name: name ?? this.name); + PlayerTableData copyWith({String? id, String? name, DateTime? createdAt}) => + PlayerTableData( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + ); PlayerTableData copyWithCompanion(PlayerTableCompanion data) { return PlayerTableData( id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -128,44 +168,52 @@ class PlayerTableData extends DataClass implements Insertable { String toString() { return (StringBuffer('PlayerTableData(') ..write('id: $id, ') - ..write('name: $name') + ..write('name: $name, ') + ..write('createdAt: $createdAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name); + int get hashCode => Object.hash(id, name, createdAt); @override bool operator ==(Object other) => identical(this, other) || (other is PlayerTableData && other.id == this.id && - other.name == this.name); + other.name == this.name && + other.createdAt == this.createdAt); } class PlayerTableCompanion extends UpdateCompanion { final Value id; final Value name; + final Value createdAt; final Value rowid; const PlayerTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), + this.createdAt = const Value.absent(), this.rowid = const Value.absent(), }); PlayerTableCompanion.insert({ required String id, required String name, + required DateTime createdAt, this.rowid = const Value.absent(), }) : id = Value(id), - name = Value(name); + name = Value(name), + createdAt = Value(createdAt); static Insertable custom({ Expression? id, Expression? name, + Expression? createdAt, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, + if (createdAt != null) 'created_at': createdAt, if (rowid != null) 'rowid': rowid, }); } @@ -173,11 +221,13 @@ class PlayerTableCompanion extends UpdateCompanion { PlayerTableCompanion copyWith({ Value? id, Value? name, + Value? createdAt, Value? rowid, }) { return PlayerTableCompanion( id: id ?? this.id, name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, rowid: rowid ?? this.rowid, ); } @@ -191,6 +241,9 @@ class PlayerTableCompanion extends UpdateCompanion { if (name.present) { map['name'] = Variable(name.value); } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -202,6 +255,7 @@ class PlayerTableCompanion extends UpdateCompanion { return (StringBuffer('PlayerTableCompanion(') ..write('id: $id, ') ..write('name: $name, ') + ..write('createdAt: $createdAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -232,8 +286,19 @@ class $GroupTableTable extends GroupTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override - List get $columns => [id, name]; + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, name, createdAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -259,6 +324,14 @@ class $GroupTableTable extends GroupTable } else if (isInserting) { context.missing(_nameMeta); } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } return context; } @@ -276,6 +349,10 @@ class $GroupTableTable extends GroupTable DriftSqlType.string, data['${effectivePrefix}name'], )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, ); } @@ -288,17 +365,27 @@ class $GroupTableTable extends GroupTable class GroupTableData extends DataClass implements Insertable { final String id; final String name; - const GroupTableData({required this.id, required this.name}); + final DateTime createdAt; + const GroupTableData({ + required this.id, + required this.name, + required this.createdAt, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['name'] = Variable(name); + map['created_at'] = Variable(createdAt); return map; } GroupTableCompanion toCompanion(bool nullToAbsent) { - return GroupTableCompanion(id: Value(id), name: Value(name)); + return GroupTableCompanion( + id: Value(id), + name: Value(name), + createdAt: Value(createdAt), + ); } factory GroupTableData.fromJson( @@ -309,6 +396,7 @@ class GroupTableData extends DataClass implements Insertable { return GroupTableData( id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), + createdAt: serializer.fromJson(json['createdAt']), ); } @override @@ -317,15 +405,21 @@ class GroupTableData extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'name': serializer.toJson(name), + 'createdAt': serializer.toJson(createdAt), }; } - GroupTableData copyWith({String? id, String? name}) => - GroupTableData(id: id ?? this.id, name: name ?? this.name); + GroupTableData copyWith({String? id, String? name, DateTime? createdAt}) => + GroupTableData( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + ); GroupTableData copyWithCompanion(GroupTableCompanion data) { return GroupTableData( id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -333,44 +427,52 @@ class GroupTableData extends DataClass implements Insertable { String toString() { return (StringBuffer('GroupTableData(') ..write('id: $id, ') - ..write('name: $name') + ..write('name: $name, ') + ..write('createdAt: $createdAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name); + int get hashCode => Object.hash(id, name, createdAt); @override bool operator ==(Object other) => identical(this, other) || (other is GroupTableData && other.id == this.id && - other.name == this.name); + other.name == this.name && + other.createdAt == this.createdAt); } class GroupTableCompanion extends UpdateCompanion { final Value id; final Value name; + final Value createdAt; final Value rowid; const GroupTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), + this.createdAt = const Value.absent(), this.rowid = const Value.absent(), }); GroupTableCompanion.insert({ required String id, required String name, + required DateTime createdAt, this.rowid = const Value.absent(), }) : id = Value(id), - name = Value(name); + name = Value(name), + createdAt = Value(createdAt); static Insertable custom({ Expression? id, Expression? name, + Expression? createdAt, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, + if (createdAt != null) 'created_at': createdAt, if (rowid != null) 'rowid': rowid, }); } @@ -378,11 +480,13 @@ class GroupTableCompanion extends UpdateCompanion { GroupTableCompanion copyWith({ Value? id, Value? name, + Value? createdAt, Value? rowid, }) { return GroupTableCompanion( id: id ?? this.id, name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, rowid: rowid ?? this.rowid, ); } @@ -396,6 +500,9 @@ class GroupTableCompanion extends UpdateCompanion { if (name.present) { map['name'] = Variable(name.value); } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -407,6 +514,7 @@ class GroupTableCompanion extends UpdateCompanion { return (StringBuffer('GroupTableCompanion(') ..write('id: $id, ') ..write('name: $name, ') + ..write('createdAt: $createdAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -451,8 +559,19 @@ class $GameTableTable extends GameTable 'REFERENCES player_table (id) ON DELETE CASCADE', ), ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override - List get $columns => [id, name, winnerId]; + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, name, winnerId, createdAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -486,6 +605,14 @@ class $GameTableTable extends GameTable } else if (isInserting) { context.missing(_winnerIdMeta); } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } return context; } @@ -507,6 +634,10 @@ class $GameTableTable extends GameTable DriftSqlType.string, data['${effectivePrefix}winner_id'], )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, ); } @@ -520,10 +651,12 @@ class GameTableData extends DataClass implements Insertable { final String id; final String name; final String winnerId; + final DateTime createdAt; const GameTableData({ required this.id, required this.name, required this.winnerId, + required this.createdAt, }); @override Map toColumns(bool nullToAbsent) { @@ -531,6 +664,7 @@ class GameTableData extends DataClass implements Insertable { map['id'] = Variable(id); map['name'] = Variable(name); map['winner_id'] = Variable(winnerId); + map['created_at'] = Variable(createdAt); return map; } @@ -539,6 +673,7 @@ class GameTableData extends DataClass implements Insertable { id: Value(id), name: Value(name), winnerId: Value(winnerId), + createdAt: Value(createdAt), ); } @@ -551,6 +686,7 @@ class GameTableData extends DataClass implements Insertable { id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), winnerId: serializer.fromJson(json['winnerId']), + createdAt: serializer.fromJson(json['createdAt']), ); } @override @@ -560,20 +696,27 @@ class GameTableData extends DataClass implements Insertable { 'id': serializer.toJson(id), 'name': serializer.toJson(name), 'winnerId': serializer.toJson(winnerId), + 'createdAt': serializer.toJson(createdAt), }; } - GameTableData copyWith({String? id, String? name, String? winnerId}) => - GameTableData( - id: id ?? this.id, - name: name ?? this.name, - winnerId: winnerId ?? this.winnerId, - ); + GameTableData copyWith({ + String? id, + String? name, + String? winnerId, + DateTime? createdAt, + }) => GameTableData( + id: id ?? this.id, + name: name ?? this.name, + winnerId: winnerId ?? this.winnerId, + createdAt: createdAt ?? this.createdAt, + ); GameTableData copyWithCompanion(GameTableCompanion data) { return GameTableData( id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, winnerId: data.winnerId.present ? data.winnerId.value : this.winnerId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -582,51 +725,59 @@ class GameTableData extends DataClass implements Insertable { return (StringBuffer('GameTableData(') ..write('id: $id, ') ..write('name: $name, ') - ..write('winnerId: $winnerId') + ..write('winnerId: $winnerId, ') + ..write('createdAt: $createdAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, name, winnerId); + int get hashCode => Object.hash(id, name, winnerId, createdAt); @override bool operator ==(Object other) => identical(this, other) || (other is GameTableData && other.id == this.id && other.name == this.name && - other.winnerId == this.winnerId); + other.winnerId == this.winnerId && + other.createdAt == this.createdAt); } class GameTableCompanion extends UpdateCompanion { final Value id; final Value name; final Value winnerId; + final Value createdAt; final Value rowid; const GameTableCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), this.winnerId = const Value.absent(), + this.createdAt = const Value.absent(), this.rowid = const Value.absent(), }); GameTableCompanion.insert({ required String id, required String name, required String winnerId, + required DateTime createdAt, this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), - winnerId = Value(winnerId); + winnerId = Value(winnerId), + createdAt = Value(createdAt); static Insertable custom({ Expression? id, Expression? name, Expression? winnerId, + Expression? createdAt, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (name != null) 'name': name, if (winnerId != null) 'winner_id': winnerId, + if (createdAt != null) 'created_at': createdAt, if (rowid != null) 'rowid': rowid, }); } @@ -635,12 +786,14 @@ class GameTableCompanion extends UpdateCompanion { Value? id, Value? name, Value? winnerId, + Value? createdAt, Value? rowid, }) { return GameTableCompanion( id: id ?? this.id, name: name ?? this.name, winnerId: winnerId ?? this.winnerId, + createdAt: createdAt ?? this.createdAt, rowid: rowid ?? this.rowid, ); } @@ -657,6 +810,9 @@ class GameTableCompanion extends UpdateCompanion { if (winnerId.present) { map['winner_id'] = Variable(winnerId.value); } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -669,6 +825,7 @@ class GameTableCompanion extends UpdateCompanion { ..write('id: $id, ') ..write('name: $name, ') ..write('winnerId: $winnerId, ') + ..write('createdAt: $createdAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -1437,12 +1594,14 @@ typedef $$PlayerTableTableCreateCompanionBuilder = PlayerTableCompanion Function({ required String id, required String name, + required DateTime createdAt, Value rowid, }); typedef $$PlayerTableTableUpdateCompanionBuilder = PlayerTableCompanion Function({ Value id, Value name, + Value createdAt, Value rowid, }); @@ -1534,6 +1693,11 @@ class $$PlayerTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + Expression gameTableRefs( Expression Function($$GameTableTableFilterComposer f) f, ) { @@ -1628,6 +1792,11 @@ class $$PlayerTableTableOrderingComposer column: $table.name, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); } class $$PlayerTableTableAnnotationComposer @@ -1645,6 +1814,9 @@ class $$PlayerTableTableAnnotationComposer GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + Expression gameTableRefs( Expression Function($$GameTableTableAnnotationComposer a) f, ) { @@ -1755,15 +1927,26 @@ class $$PlayerTableTableTableManager ({ Value id = const Value.absent(), Value name = const Value.absent(), + Value createdAt = const Value.absent(), Value rowid = const Value.absent(), - }) => PlayerTableCompanion(id: id, name: name, rowid: rowid), + }) => PlayerTableCompanion( + id: id, + name: name, + createdAt: createdAt, + rowid: rowid, + ), createCompanionCallback: ({ required String id, required String name, + required DateTime createdAt, Value rowid = const Value.absent(), - }) => - PlayerTableCompanion.insert(id: id, name: name, rowid: rowid), + }) => PlayerTableCompanion.insert( + id: id, + name: name, + createdAt: createdAt, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 .map( (e) => ( @@ -1881,12 +2064,14 @@ typedef $$GroupTableTableCreateCompanionBuilder = GroupTableCompanion Function({ required String id, required String name, + required DateTime createdAt, Value rowid, }); typedef $$GroupTableTableUpdateCompanionBuilder = GroupTableCompanion Function({ Value id, Value name, + Value createdAt, Value rowid, }); @@ -1958,6 +2143,11 @@ class $$GroupTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + Expression playerGroupTableRefs( Expression Function($$PlayerGroupTableTableFilterComposer f) f, ) { @@ -2027,6 +2217,11 @@ class $$GroupTableTableOrderingComposer column: $table.name, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); } class $$GroupTableTableAnnotationComposer @@ -2044,6 +2239,9 @@ class $$GroupTableTableAnnotationComposer GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + Expression playerGroupTableRefs( Expression Function($$PlayerGroupTableTableAnnotationComposer a) f, ) { @@ -2128,15 +2326,26 @@ class $$GroupTableTableTableManager ({ Value id = const Value.absent(), Value name = const Value.absent(), + Value createdAt = const Value.absent(), Value rowid = const Value.absent(), - }) => GroupTableCompanion(id: id, name: name, rowid: rowid), + }) => GroupTableCompanion( + id: id, + name: name, + createdAt: createdAt, + rowid: rowid, + ), createCompanionCallback: ({ required String id, required String name, + required DateTime createdAt, Value rowid = const Value.absent(), - }) => - GroupTableCompanion.insert(id: id, name: name, rowid: rowid), + }) => GroupTableCompanion.insert( + id: id, + name: name, + createdAt: createdAt, + rowid: rowid, + ), withReferenceMapper: (p0) => p0 .map( (e) => ( @@ -2228,6 +2437,7 @@ typedef $$GameTableTableCreateCompanionBuilder = required String id, required String name, required String winnerId, + required DateTime createdAt, Value rowid, }); typedef $$GameTableTableUpdateCompanionBuilder = @@ -2235,6 +2445,7 @@ typedef $$GameTableTableUpdateCompanionBuilder = Value id, Value name, Value winnerId, + Value createdAt, Value rowid, }); @@ -2319,6 +2530,11 @@ class $$GameTableTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + $$PlayerTableTableFilterComposer get winnerId { final $$PlayerTableTableFilterComposer composer = $composerBuilder( composer: this, @@ -2412,6 +2628,11 @@ class $$GameTableTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + $$PlayerTableTableOrderingComposer get winnerId { final $$PlayerTableTableOrderingComposer composer = $composerBuilder( composer: this, @@ -2451,6 +2672,9 @@ class $$GameTableTableAnnotationComposer GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + $$PlayerTableTableAnnotationComposer get winnerId { final $$PlayerTableTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -2560,11 +2784,13 @@ class $$GameTableTableTableManager Value id = const Value.absent(), Value name = const Value.absent(), Value winnerId = const Value.absent(), + Value createdAt = const Value.absent(), Value rowid = const Value.absent(), }) => GameTableCompanion( id: id, name: name, winnerId: winnerId, + createdAt: createdAt, rowid: rowid, ), createCompanionCallback: @@ -2572,11 +2798,13 @@ class $$GameTableTableTableManager required String id, required String name, required String winnerId, + required DateTime createdAt, Value rowid = const Value.absent(), }) => GameTableCompanion.insert( id: id, name: name, winnerId: winnerId, + createdAt: createdAt, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/data/db/tables/game_table.dart b/lib/data/db/tables/game_table.dart index 9651a79..0fe5a3c 100644 --- a/lib/data/db/tables/game_table.dart +++ b/lib/data/db/tables/game_table.dart @@ -6,6 +6,7 @@ class GameTable extends Table { TextColumn get name => text()(); TextColumn get winnerId => text().references(PlayerTable, #id, onDelete: KeyAction.cascade)(); + DateTimeColumn get createdAt => dateTime()(); @override Set> get primaryKey => {id}; diff --git a/lib/data/db/tables/group_table.dart b/lib/data/db/tables/group_table.dart index dc9335d..5c52355 100644 --- a/lib/data/db/tables/group_table.dart +++ b/lib/data/db/tables/group_table.dart @@ -3,6 +3,7 @@ import 'package:drift/drift.dart'; class GroupTable extends Table { TextColumn get id => text()(); TextColumn get name => text()(); + DateTimeColumn get createdAt => dateTime()(); @override Set> get primaryKey => {id}; diff --git a/lib/data/db/tables/player_table.dart b/lib/data/db/tables/player_table.dart index 3d97459..794958e 100644 --- a/lib/data/db/tables/player_table.dart +++ b/lib/data/db/tables/player_table.dart @@ -3,6 +3,7 @@ import 'package:drift/drift.dart'; class PlayerTable extends Table { TextColumn get id => text()(); TextColumn get name => text()(); + DateTimeColumn get createdAt => dateTime()(); @override Set> get primaryKey => {id}; diff --git a/lib/data/dto/game.dart b/lib/data/dto/game.dart index c84779d..96e9d73 100644 --- a/lib/data/dto/game.dart +++ b/lib/data/dto/game.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:game_tracker/data/dto/group.dart'; import 'package:game_tracker/data/dto/player.dart'; import 'package:uuid/uuid.dart'; @@ -8,14 +9,17 @@ class Game { final List? players; final Group? group; final String winner; + final DateTime createdAt; Game({ String? id, + DateTime? createdAt, required this.name, this.players, this.group, this.winner = '', - }) : id = id ?? const Uuid().v4(); + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? clock.now(); @override String toString() { diff --git a/lib/data/dto/group.dart b/lib/data/dto/group.dart index 0420477..46c6f91 100644 --- a/lib/data/dto/group.dart +++ b/lib/data/dto/group.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:game_tracker/data/dto/player.dart'; import 'package:uuid/uuid.dart'; @@ -5,9 +6,15 @@ class Group { final String id; final String name; final List members; + final DateTime createdAt; - Group({String? id, required this.name, required this.members}) - : id = id ?? const Uuid().v4(); + Group({ + String? id, + DateTime? createdAt, + required this.name, + required this.members, + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? clock.now(); @override String toString() { diff --git a/lib/data/dto/player.dart b/lib/data/dto/player.dart index 1b00c2c..4ef58b1 100644 --- a/lib/data/dto/player.dart +++ b/lib/data/dto/player.dart @@ -1,10 +1,14 @@ +import 'package:clock/clock.dart'; import 'package:uuid/uuid.dart'; class Player { final String id; final String name; + final DateTime createdAt; - Player({String? id, required this.name}) : id = id ?? const Uuid().v4(); + Player({String? id, DateTime? createdAt, required this.name}) + : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? clock.now(); @override String toString() { diff --git a/lib/presentation/views/main_menu/create_group_view.dart b/lib/presentation/views/main_menu/create_group_view.dart new file mode 100644 index 0000000..c54369e --- /dev/null +++ b/lib/presentation/views/main_menu/create_group_view.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart' hide ButtonStyle; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; +import 'package:game_tracker/data/db/database.dart'; +import 'package:game_tracker/data/dto/group.dart'; +import 'package:game_tracker/data/dto/player.dart'; +import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; +import 'package:game_tracker/presentation/widgets/custom_search_bar.dart'; +import 'package:game_tracker/presentation/widgets/text_input_field.dart'; +import 'package:game_tracker/presentation/widgets/tiles/text_icon_list_tile.dart'; +import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart'; +import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; +import 'package:provider/provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class CreateGroupView extends StatefulWidget { + const CreateGroupView({super.key}); + + @override + State createState() => _CreateGroupViewState(); +} + +class _CreateGroupViewState extends State { + List selectedPlayers = []; + List suggestedPlayers = []; + List allPlayers = []; + late final AppDatabase db; + late Future> _allPlayersFuture; + late final List skeletonData = List.filled( + 7, + Player(name: 'Player 0'), + ); + final _groupNameController = TextEditingController(); + final _searchBarController = TextEditingController(); + + @override + void initState() { + super.initState(); + db = Provider.of(context, listen: false); + _searchBarController.addListener(() { + setState(() {}); + }); + _groupNameController.addListener(() { + setState(() {}); + }); + loadPlayerList(); + } + + @override + void dispose() { + _groupNameController.dispose(); + _searchBarController + .dispose(); // Listener entfernen und Controller aufräumen + super.dispose(); + } + + void loadPlayerList() { + _allPlayersFuture = db.playerDao.getAllPlayers(); + _allPlayersFuture.then((loadedPlayers) { + setState(() { + loadedPlayers.sort((a, b) => a.name.compareTo(b.name)); + allPlayers = [...loadedPlayers]; + suggestedPlayers = [...loadedPlayers]; + }); + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: CustomTheme.backgroundColor, + appBar: AppBar( + backgroundColor: CustomTheme.backgroundColor, + scrolledUnderElevation: 0, + title: const Text( + 'Create new group', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + centerTitle: true, + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: TextInputField( + controller: _groupNameController, + hintText: 'Group name', + onChanged: (value) { + setState(() {}); + }, + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 10, + ), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomSearchBar( + controller: _searchBarController, + constraints: const BoxConstraints( + maxHeight: 45, + minHeight: 45, + ), + hintText: 'Search for players', + trailingButtonShown: true, + trailingButtonicon: Icons.add_circle, + trailingButtonEnabled: _searchBarController.text + .trim() + .isNotEmpty, + onTrailingButtonPressed: () async { + addNewPlayerFromSearch( + context: context, + searchBarController: _searchBarController, + db: db, + loadPlayerList: loadPlayerList, + ); + }, + onChanged: (value) { + setState(() { + if (value.isEmpty) { + suggestedPlayers = allPlayers.where((player) { + return !selectedPlayers.contains(player); + }).toList(); + } else { + suggestedPlayers = allPlayers.where((player) { + final bool nameMatches = player.name + .toLowerCase() + .contains(value.toLowerCase()); + final bool isNotSelected = !selectedPlayers + .contains(player); + return nameMatches && isNotSelected; + }).toList(); + } + }); + }, + ), + const SizedBox(height: 10), + Text( + 'Ausgewählte Spieler: (${selectedPlayers.length})', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 8.0, + runSpacing: 8.0, + children: [ + for (var player in selectedPlayers) + TextIconTile( + text: player.name, + onIconTap: () { + setState(() { + final currentSearch = _searchBarController.text + .toLowerCase(); + selectedPlayers.remove(player); + if (currentSearch.isEmpty || + player.name.toLowerCase().contains( + currentSearch, + )) { + suggestedPlayers.add(player); + suggestedPlayers.sort( + (a, b) => a.name.compareTo(b.name), + ); + } + }); + }, + ), + ], + ), + const SizedBox(height: 10), + const Text( + 'Alle Spieler:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + FutureBuilder( + future: _allPlayersFuture, + builder: + ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { + if (snapshot.hasError) { + return const Center( + child: TopCenteredMessage( + icon: Icons.report, + title: 'Error', + message: 'Player data couldn\'t\nbe loaded.', + ), + ); + } + if (snapshot.connectionState == + ConnectionState.done && + (!snapshot.hasData || + snapshot.data!.isEmpty || + (selectedPlayers.isEmpty && + allPlayers.isEmpty))) { + return const Center( + child: TopCenteredMessage( + icon: Icons.info, + title: 'Info', + message: 'No players created yet.', + ), + ); + } + final bool isLoading = + snapshot.connectionState == + ConnectionState.waiting; + return Expanded( + child: Skeletonizer( + effect: PulseEffect( + from: Colors.grey[800]!, + to: Colors.grey[600]!, + duration: const Duration(milliseconds: 800), + ), + enabled: isLoading, + enableSwitchAnimation: true, + switchAnimationConfig: + const SwitchAnimationConfig( + duration: Duration(milliseconds: 200), + switchInCurve: Curves.linear, + switchOutCurve: Curves.linear, + transitionBuilder: AnimatedSwitcher + .defaultTransitionBuilder, + layoutBuilder: + AnimatedSwitcher.defaultLayoutBuilder, + ), + child: Visibility( + visible: + (suggestedPlayers.isEmpty && + allPlayers.isNotEmpty), + replacement: ListView.builder( + itemCount: suggestedPlayers.length, + itemBuilder: + (BuildContext context, int index) { + return TextIconListTile( + text: suggestedPlayers[index].name, + onPressed: () { + setState(() { + if (!selectedPlayers.contains( + suggestedPlayers[index], + )) { + selectedPlayers.add( + suggestedPlayers[index], + ); + selectedPlayers.sort( + (a, b) => a.name.compareTo( + b.name, + ), + ); + suggestedPlayers.remove( + suggestedPlayers[index], + ); + } + }); + }, + ); + }, + ), + child: TopCenteredMessage( + icon: Icons.info, + title: 'Info', + message: + (selectedPlayers.length == + allPlayers.length) + ? 'No more players to add.' + : 'No players found with that name.', + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + CustomWidthButton( + text: 'Create group', + sizeRelativeToWidth: 0.95, + buttonType: ButtonType.primary, + onPressed: + (_groupNameController.text.isEmpty || selectedPlayers.isEmpty) + ? null + : () async { + bool success = await db.groupDao.addGroup( + group: Group( + name: _groupNameController.text.trim(), + members: selectedPlayers, + ), + ); + if (!context.mounted) return; + if (success) { + _groupNameController.clear(); + _searchBarController.clear(); + selectedPlayers.clear(); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: const Center( + child: Text( + 'Error while creating group, please try again', + style: TextStyle(color: Colors.white), + ), + ), + ), + ); + } + setState(() {}); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} + +/// Adds a new player to the database from the search bar input. +/// Shows a snackbar indicating success or failure. +/// [context] - BuildContext to show the snackbar. +/// [searchBarController] - TextEditingController of the search bar. +/// [db] - AppDatabase instance to interact with the database. +/// [loadPlayerList] - Function to reload the player list after adding. +void addNewPlayerFromSearch({ + required BuildContext context, + required TextEditingController searchBarController, + required AppDatabase db, + required Function loadPlayerList, +}) async { + String playerName = searchBarController.text.trim(); + bool success = await db.playerDao.addPlayer(player: Player(name: playerName)); + if (!context.mounted) return; + if (success) { + loadPlayerList(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Successfully added player $playerName.', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + searchBarController.clear(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: CustomTheme.boxColor, + content: Center( + child: Text( + 'Could not add player $playerName.', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/views/main_menu/groups_view.dart b/lib/presentation/views/main_menu/groups_view.dart index 8edf20f..c45cf21 100644 --- a/lib/presentation/views/main_menu/groups_view.dart +++ b/lib/presentation/views/main_menu/groups_view.dart @@ -3,7 +3,8 @@ import 'package:game_tracker/core/custom_theme.dart'; import 'package:game_tracker/data/db/database.dart'; import 'package:game_tracker/data/dto/group.dart'; import 'package:game_tracker/data/dto/player.dart'; -import 'package:game_tracker/presentation/widgets/full_width_button.dart'; +import 'package:game_tracker/presentation/views/main_menu/create_group_view.dart'; +import 'package:game_tracker/presentation/widgets/buttons/custom_width_button.dart'; import 'package:game_tracker/presentation/widgets/tiles/group_tile.dart'; import 'package:game_tracker/presentation/widgets/top_centered_message.dart'; import 'package:provider/provider.dart'; @@ -18,6 +19,7 @@ class GroupsView extends StatefulWidget { class _GroupsViewState extends State { late Future> _allGroupsFuture; + late final AppDatabase db; final player = Player(name: 'Skeleton Player'); late final List skeletonData = List.filled( @@ -31,7 +33,7 @@ class _GroupsViewState extends State { @override void initState() { super.initState(); - final db = Provider.of(context, listen: false); + db = Provider.of(context, listen: false); _allGroupsFuture = db.groupDao.getAllGroups(); } @@ -102,7 +104,23 @@ class _GroupsViewState extends State { Positioned( bottom: 80, - child: FullWidthButton(text: 'Create Group', onPressed: () {}), + child: CustomWidthButton( + text: 'Create Group', + sizeRelativeToWidth: 0.90, + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const CreateGroupView(); + }, + ), + ); + setState(() { + _allGroupsFuture = db.groupDao.getAllGroups(); + }); + }, + ), ), ], ), diff --git a/lib/presentation/views/main_menu/home_view.dart b/lib/presentation/views/main_menu/home_view.dart index cf6288a..34e4be3 100644 --- a/lib/presentation/views/main_menu/home_view.dart +++ b/lib/presentation/views/main_menu/home_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:game_tracker/data/db/database.dart'; -import 'package:game_tracker/presentation/widgets/game_tile.dart'; -import 'package:game_tracker/presentation/widgets/quick_create_button.dart'; +import 'package:game_tracker/presentation/widgets/buttons/quick_create_button.dart'; +import 'package:game_tracker/presentation/widgets/tiles/game_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/info_tile.dart'; import 'package:game_tracker/presentation/widgets/tiles/quick_info_tile.dart'; import 'package:provider/provider.dart'; diff --git a/lib/presentation/widgets/buttons/custom_width_button.dart b/lib/presentation/widgets/buttons/custom_width_button.dart new file mode 100644 index 0000000..17c9dc5 --- /dev/null +++ b/lib/presentation/widgets/buttons/custom_width_button.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; +import 'package:game_tracker/core/enums.dart'; + +class CustomWidthButton extends StatelessWidget { + const CustomWidthButton({ + super.key, + required this.text, + this.buttonType = ButtonType.primary, + required this.sizeRelativeToWidth, + this.onPressed, + }); + + final String text; + final double sizeRelativeToWidth; + final VoidCallback? onPressed; + final ButtonType buttonType; + + @override + Widget build(BuildContext context) { + final Color buttonBackgroundColor; + final Color disabledBackgroundColor; + final Color borderSideColor; + final Color textcolor; + final Color disabledTextColor; + + if (buttonType == ButtonType.primary) { + textcolor = Colors.white; + disabledTextColor = Color.lerp(textcolor, Colors.black, 0.5)!; + buttonBackgroundColor = CustomTheme.primaryColor; + disabledBackgroundColor = Color.lerp( + buttonBackgroundColor, + Colors.black, + 0.5, + )!; + + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + foregroundColor: textcolor, + disabledForegroundColor: disabledTextColor, + backgroundColor: buttonBackgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + animationDuration: const Duration(), + minimumSize: Size( + MediaQuery.sizeOf(context).width * sizeRelativeToWidth, + 60, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 22), + ), + ); + } else if (buttonType == ButtonType.secondary) { + textcolor = CustomTheme.primaryColor; + disabledTextColor = Color.lerp(textcolor, Colors.black, 0.5)!; + buttonBackgroundColor = Colors.transparent; + disabledBackgroundColor = Colors.transparent; + borderSideColor = onPressed != null + ? CustomTheme.primaryColor + : Color.lerp(CustomTheme.primaryColor, Colors.black, 0.5)!; + + return OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: textcolor, + disabledForegroundColor: disabledTextColor, + backgroundColor: buttonBackgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + animationDuration: const Duration(), + minimumSize: Size( + MediaQuery.sizeOf(context).width * sizeRelativeToWidth, + 60, + ), + side: BorderSide(color: borderSideColor, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 22), + ), + ); + } else { + textcolor = CustomTheme.primaryColor; + disabledTextColor = Color.lerp( + CustomTheme.primaryColor, + Colors.black, + 0.5, + )!; + buttonBackgroundColor = Colors.transparent; + disabledBackgroundColor = Colors.transparent; + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: textcolor, + disabledForegroundColor: disabledTextColor, + backgroundColor: buttonBackgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + animationDuration: const Duration(), + minimumSize: Size( + MediaQuery.sizeOf(context).width * sizeRelativeToWidth, + 60, + ), + side: const BorderSide(style: BorderStyle.none), + ), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 22), + ), + ); + } + } +} diff --git a/lib/presentation/widgets/quick_create_button.dart b/lib/presentation/widgets/buttons/quick_create_button.dart similarity index 100% rename from lib/presentation/widgets/quick_create_button.dart rename to lib/presentation/widgets/buttons/quick_create_button.dart diff --git a/lib/presentation/widgets/custom_search_bar.dart b/lib/presentation/widgets/custom_search_bar.dart new file mode 100644 index 0000000..e3fe976 --- /dev/null +++ b/lib/presentation/widgets/custom_search_bar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class CustomSearchBar extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged? onChanged; + final BoxConstraints? constraints; + final bool trailingButtonShown; + final bool trailingButtonEnabled; + final VoidCallback? onTrailingButtonPressed; + final IconData trailingButtonicon; + + const CustomSearchBar({ + super.key, + required this.controller, + required this.hintText, + this.trailingButtonShown = false, + this.trailingButtonicon = Icons.clear, + this.trailingButtonEnabled = true, + this.onTrailingButtonPressed, + this.onChanged, + this.constraints, + }); + + @override + Widget build(BuildContext context) { + return SearchBar( + controller: controller, + constraints: + constraints ?? const BoxConstraints(maxHeight: 45, minHeight: 45), + hintText: hintText, + onChanged: trailingButtonEnabled ? onChanged : null, + hintStyle: WidgetStateProperty.all(const TextStyle(fontSize: 16)), + leading: const Icon(Icons.search), + trailing: [ + Visibility( + visible: trailingButtonShown, + child: GestureDetector( + onTap: trailingButtonEnabled ? onTrailingButtonPressed : null, + child: Icon( + trailingButtonicon, + color: trailingButtonEnabled + ? null + : Colors.grey.withValues(alpha: 0.2), + ), + ), + ), + const SizedBox(width: 5), + ], + backgroundColor: WidgetStateProperty.all(CustomTheme.boxColor), + side: WidgetStateProperty.all(BorderSide(color: CustomTheme.boxBorder)), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + elevation: WidgetStateProperty.all(0), + ); + } +} diff --git a/lib/presentation/widgets/full_width_button.dart b/lib/presentation/widgets/full_width_button.dart deleted file mode 100644 index bd18c64..0000000 --- a/lib/presentation/widgets/full_width_button.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:game_tracker/core/custom_theme.dart'; - -class FullWidthButton extends StatelessWidget { - const FullWidthButton({super.key, required this.text, this.onPressed}); - - final String text; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - minimumSize: Size(MediaQuery.sizeOf(context).width * 0.9, 60), - backgroundColor: CustomTheme.primaryColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: Text( - text, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 22, - color: Colors.white, - ), - ), - ); - } -} diff --git a/lib/presentation/widgets/text_input_field.dart b/lib/presentation/widgets/text_input_field.dart new file mode 100644 index 0000000..6cd9d75 --- /dev/null +++ b/lib/presentation/widgets/text_input_field.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class TextInputField extends StatelessWidget { + final TextEditingController controller; + final ValueChanged? onChanged; + final String hintText; + + const TextInputField({ + super.key, + required this.controller, + required this.hintText, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + filled: true, + fillColor: CustomTheme.boxColor, + hintText: hintText, + hintStyle: const TextStyle(fontSize: 18), + enabledBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: CustomTheme.boxBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: CustomTheme.boxBorder), + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ); + } +} diff --git a/lib/presentation/widgets/double_row_info_tile.dart b/lib/presentation/widgets/tiles/double_row_info_tile.dart similarity index 72% rename from lib/presentation/widgets/double_row_info_tile.dart rename to lib/presentation/widgets/tiles/double_row_info_tile.dart index 621cc74..57404ff 100644 --- a/lib/presentation/widgets/double_row_info_tile.dart +++ b/lib/presentation/widgets/tiles/double_row_info_tile.dart @@ -9,8 +9,8 @@ Widget doubleRowInfoTile( String titleLowerRight, ) { return Container( - margin: EdgeInsets.symmetric(vertical: 5, horizontal: 10), - padding: EdgeInsets.all(10), + margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: CustomTheme.secondaryColor, @@ -22,18 +22,18 @@ Widget doubleRowInfoTile( Expanded( flex: 10, child: Text( - "$titleOneUpperLeft $titleTwoUpperLeft", - style: TextStyle(fontSize: 20), + '$titleOneUpperLeft $titleTwoUpperLeft', + style: const TextStyle(fontSize: 20), overflow: TextOverflow.ellipsis, maxLines: 1, ), ), - Spacer(), + const Spacer(), Expanded( flex: 3, child: Text( - "$titleUpperRight", - style: TextStyle(fontSize: 20), + titleUpperRight, + style: const TextStyle(fontSize: 20), overflow: TextOverflow.ellipsis, maxLines: 1, textAlign: TextAlign.end, @@ -46,18 +46,18 @@ Widget doubleRowInfoTile( Expanded( flex: 10, child: Text( - "$titleLowerLeft", - style: TextStyle(fontSize: 20), + titleLowerLeft, + style: const TextStyle(fontSize: 20), overflow: TextOverflow.ellipsis, maxLines: 1, ), ), - Spacer(), + const Spacer(), Expanded( flex: 4, child: Text( - "$titleLowerRight", - style: TextStyle(fontSize: 20), + titleLowerRight, + style: const TextStyle(fontSize: 20), overflow: TextOverflow.ellipsis, maxLines: 1, textAlign: TextAlign.end, diff --git a/lib/presentation/widgets/game_tile.dart b/lib/presentation/widgets/tiles/game_tile.dart similarity index 100% rename from lib/presentation/widgets/game_tile.dart rename to lib/presentation/widgets/tiles/game_tile.dart diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index 448c68c..fa91477 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:game_tracker/core/custom_theme.dart'; import 'package:game_tracker/data/dto/group.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:game_tracker/presentation/widgets/tiles/text_icon_tile.dart'; class GroupTile extends StatelessWidget { const GroupTile({super.key, required this.group}); @@ -24,24 +24,29 @@ class GroupTile extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - group.name, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + Flexible( + child: Text( + group.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), ), ), - const Spacer(), - Text( - '${group.members.length}', - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 18, - ), + Row( + children: [ + Text( + '${group.members.length}', + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 18, + ), + ), + const SizedBox(width: 3), + const Icon(Icons.group, size: 22), + ], ), - const SizedBox(width: 3), - const Icon(Icons.group, size: 22), ], ), const SizedBox(height: 5), @@ -52,25 +57,7 @@ class GroupTile extends StatelessWidget { runSpacing: 8.0, children: [ for (var member in group.members) - Container( - padding: const EdgeInsets.symmetric( - vertical: 5, - horizontal: 10, - ), - decoration: BoxDecoration( - color: CustomTheme.onBoxColor, - borderRadius: BorderRadius.circular(12), - ), - child: Skeleton.ignore( - child: Text( - member.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - ), + TextIconTile(text: member.name, iconEnabled: false), ], ), const SizedBox(height: 2.5), diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart new file mode 100644 index 0000000..5e272c9 --- /dev/null +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class TextIconListTile extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool iconEnabled; + + const TextIconListTile({ + super.key, + required this.text, + this.onPressed, + this.iconEnabled = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 15), + decoration: BoxDecoration( + color: CustomTheme.boxColor, + border: Border.all(color: CustomTheme.boxBorder), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12.5), + child: Text( + text, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + if (iconEnabled) + GestureDetector( + onTap: onPressed, + child: const Icon(Icons.add, size: 20), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/tiles/text_icon_tile.dart b/lib/presentation/widgets/tiles/text_icon_tile.dart new file mode 100644 index 0000000..2544837 --- /dev/null +++ b/lib/presentation/widgets/tiles/text_icon_tile.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:game_tracker/core/custom_theme.dart'; + +class TextIconTile extends StatelessWidget { + final String text; + final bool iconEnabled; + final VoidCallback? onIconTap; + + const TextIconTile({ + super.key, + required this.text, + this.onIconTap, + this.iconEnabled = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: CustomTheme.onBoxColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + if (iconEnabled) const SizedBox(width: 3), + Flexible( + child: Text( + text, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + if (iconEnabled) ...[ + const SizedBox(width: 3), + GestureDetector( + onTap: onIconTap, + child: const Icon(Icons.close, size: 20), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fbbc01a..b17f409 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: provider: ^6.1.5 skeletonizer: ^2.1.0+1 uuid: ^4.5.2 + clock: ^1.1.2 dev_dependencies: flutter_test: diff --git a/test/db_tests/game_test.dart b/test/db_tests/game_test.dart index 4f4b23f..d726425 100644 --- a/test/db_tests/game_test.dart +++ b/test/db_tests/game_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,6 +16,8 @@ void main() { late Player player5; late Group testgroup; late Game testgame; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); setUp(() { database = AppDatabase( @@ -25,17 +28,22 @@ void main() { ), ); - player1 = Player(name: 'Alice'); - player2 = Player(name: 'Bob'); - player3 = Player(name: 'Charlie'); - player4 = Player(name: 'Diana'); - player5 = Player(name: 'Eve'); - testgroup = Group(name: 'Test Group', members: [player1, player2, player3]); - testgame = Game( - name: 'Test Game', - group: testgroup, - players: [player4, player5], - ); + withClock(fakeClock, () { + player1 = Player(name: 'Alice'); + player2 = Player(name: 'Bob'); + player3 = Player(name: 'Charlie'); + player4 = Player(name: 'Diana'); + player5 = Player(name: 'Eve'); + testgroup = Group( + name: 'Test Group', + members: [player1, player2, player3], + ); + testgame = Game( + name: 'Test Game', + group: testgroup, + players: [player4, player5], + ); + }); }); tearDown(() async { await database.close(); @@ -50,6 +58,7 @@ void main() { expect(result.id, testgame.id); expect(result.name, testgame.name); expect(result.winner, testgame.winner); + expect(result.createdAt, testgame.createdAt); if (result.group != null) { expect(result.group!.members.length, testgroup.members.length); @@ -67,6 +76,7 @@ void main() { for (int i = 0; i < testgame.players!.length; i++) { expect(result.players![i].id, testgame.players![i].id); expect(result.players![i].name, testgame.players![i].name); + expect(result.players![i].createdAt, testgame.players![i].createdAt); } } else { fail('Players is null'); diff --git a/test/db_tests/group_test.dart b/test/db_tests/group_test.dart index 3a9d8ca..a076ab0 100644 --- a/test/db_tests/group_test.dart +++ b/test/db_tests/group_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +13,9 @@ void main() { late Player player3; late Player player4; late Group testgroup; + late Group testgroup2; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); setUp(() { database = AppDatabase( @@ -22,22 +26,27 @@ void main() { ), ); - player1 = Player(name: 'Alice'); - player2 = Player(name: 'Bob'); - player3 = Player(name: 'Charlie'); - player4 = Player(name: 'Diana'); - testgroup = Group(name: 'Test Group', members: [player1, player2, player3]); + withClock(fakeClock, () { + player1 = Player(name: 'Alice'); + player2 = Player(name: 'Bob'); + player3 = Player(name: 'Charlie'); + player4 = Player(name: 'Diana'); + testgroup = Group( + name: 'Test Group', + members: [player1, player2, player3], + ); + testgroup2 = Group( + id: 'gr2', + name: 'Second Group', + members: [player2, player3, player4], + ); + }); }); tearDown(() async { await database.close(); }); group('group tests', () { test('all groups get fetched correctly', () async { - final testgroup2 = Group( - id: 'gr2', - name: 'Second Group', - members: [player2, player3, player4], - ); await database.groupDao.addGroup(group: testgroup); await database.groupDao.addGroup(group: testgroup2); @@ -48,11 +57,13 @@ void main() { expect(fetchedGroup1.name, testgroup.name); expect(fetchedGroup1.members.length, testgroup.members.length); expect(fetchedGroup1.members.elementAt(0).id, player1.id); + expect(fetchedGroup1.members.elementAt(0).createdAt, player1.createdAt); final fetchedGroup2 = allGroups.firstWhere((g) => g.id == testgroup2.id); expect(fetchedGroup2.name, testgroup2.name); expect(fetchedGroup2.members.length, testgroup2.members.length); expect(fetchedGroup2.members.elementAt(0).id, player2.id); + expect(fetchedGroup2.members.elementAt(0).createdAt, player2.createdAt); }); test('group and group members gets added correctly', () async { @@ -64,11 +75,13 @@ void main() { expect(result.id, testgroup.id); expect(result.name, testgroup.name); + expect(result.createdAt, testgroup.createdAt); expect(result.members.length, testgroup.members.length); for (int i = 0; i < testgroup.members.length; i++) { expect(result.members[i].id, testgroup.members[i].id); expect(result.members[i].name, testgroup.members[i].name); + expect(result.members[i].createdAt, testgroup.members[i].createdAt); } }); @@ -124,8 +137,6 @@ void main() { expect(playerNotAdded, true); - expect(playerAdded, true); - final result = await database.groupDao.getGroupById( groupId: testgroup.id, ); @@ -133,6 +144,7 @@ void main() { final addedPlayer = result.members.firstWhere((p) => p.id == player4.id); expect(addedPlayer.name, player4.name); + expect(addedPlayer.createdAt, player4.createdAt); }); test('Removing player from group works correctly', () async { diff --git a/test/db_tests/player_test.dart b/test/db_tests/player_test.dart index 91f4acb..fa65f67 100644 --- a/test/db_tests/player_test.dart +++ b/test/db_tests/player_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +8,9 @@ import 'package:game_tracker/data/dto/player.dart'; void main() { late AppDatabase database; late Player testPlayer; + late Player testPlayer2; + final fixedDate = DateTime(2025, 19, 11, 00, 11, 23); + final fakeClock = Clock(() => fixedDate); setUp(() { database = AppDatabase( @@ -17,7 +21,10 @@ void main() { ), ); - testPlayer = Player(name: 'Test Player'); + withClock(fakeClock, () { + testPlayer = Player(name: 'Test Player'); + testPlayer2 = Player(name: 'Second Group'); + }); }); tearDown(() async { await database.close(); @@ -25,7 +32,6 @@ void main() { group('player tests', () { test('all players get fetched correctly', () async { - final testPlayer2 = Player(name: 'Second Group'); await database.playerDao.addPlayer(player: testPlayer); await database.playerDao.addPlayer(player: testPlayer2); @@ -36,11 +42,13 @@ void main() { (g) => g.id == testPlayer.id, ); expect(fetchedPlayer1.name, testPlayer.name); + expect(fetchedPlayer1.createdAt, testPlayer.createdAt); final fetchedPlayer2 = allPlayers.firstWhere( (g) => g.id == testPlayer2.id, ); expect(fetchedPlayer2.name, testPlayer2.name); + expect(fetchedPlayer2.createdAt, testPlayer2.createdAt); }); test('players get inserted correcly ', () async { @@ -51,6 +59,7 @@ void main() { expect(result.id, testPlayer.id); expect(result.name, testPlayer.name); + expect(result.createdAt, testPlayer.createdAt); }); test('players get deleted correcly ', () async {