Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## [1.8.0]

#### New Features

- Added support for fractional level IDs (0.1, 0.2).
- Renamed classic level 0 to 0.1.
- Added new level 0.2.

#### Improvements

- Database migration to support new level ID format.
- Existing users' progress is preserved automatically.

## [1.7.0]

#### New Features
Expand Down
12 changes: 6 additions & 6 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = G4KB5U6326;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
Expand All @@ -505,7 +505,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.khlebobul.pegma;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand Down Expand Up @@ -683,7 +683,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = G4KB5U6326;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
Expand All @@ -693,7 +693,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.khlebobul.pegma;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand All @@ -709,7 +709,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = G4KB5U6326;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
Expand All @@ -719,7 +719,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.khlebobul.pegma;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
Expand Down
70 changes: 57 additions & 13 deletions lib/core/database/database_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class DatabaseHelper {

return await openDatabase(
path,
version: 5,
version: 6,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
Expand Down Expand Up @@ -62,13 +62,57 @@ class DatabaseHelper {
whereArgs: [10, 45, 48],
);
}

if (oldVersion < 6) {
// Migrate level_id from INTEGER to TEXT to support fractional levels (0.1, 0.2, etc.)
// Also migrate level 0 to 0.1 for users who already completed it

// Create new tables with TEXT level_id
await db.execute('''
CREATE TABLE completed_levels_new (
level_id TEXT PRIMARY KEY,
completed_at TEXT NOT NULL,
moves_count INTEGER NOT NULL
)
''');

await db.execute('''
CREATE TABLE saved_games_new (
level_id TEXT PRIMARY KEY,
board_state TEXT NOT NULL,
moves_count INTEGER NOT NULL,
saved_at TEXT NOT NULL
)
''');

// Migrate data, converting level 0 to 0.1
await db.execute('''
INSERT INTO completed_levels_new (level_id, completed_at, moves_count)
SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
completed_at, moves_count
FROM completed_levels
''');

await db.execute('''
INSERT INTO saved_games_new (level_id, board_state, moves_count, saved_at)
SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
board_state, moves_count, saved_at
FROM saved_games
''');

// Drop old tables and rename new ones
await db.execute('DROP TABLE completed_levels');
await db.execute('DROP TABLE saved_games');
await db.execute('ALTER TABLE completed_levels_new RENAME TO completed_levels');
await db.execute('ALTER TABLE saved_games_new RENAME TO saved_games');
}
Comment on lines +66 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wrap migration in a transaction to prevent data loss on partial failure.

The migration performs multiple DDL operations sequentially. If any statement fails after DROP TABLE (lines 104-105) but before ALTER TABLE ... RENAME (lines 106-107), the database will be left in an inconsistent state with potential data loss.

🛡️ Proposed fix: Wrap in transaction
     if (oldVersion < 6) {
       // Migrate level_id from INTEGER to TEXT to support fractional levels (0.1, 0.2, etc.)
       // Also migrate level 0 to 0.1 for users who already completed it
+      await db.transaction((txn) async {
+        // Create new tables with TEXT level_id
+        await txn.execute('''
+          CREATE TABLE completed_levels_new (
+            level_id TEXT PRIMARY KEY,
+            completed_at TEXT NOT NULL,
+            moves_count INTEGER NOT NULL
+          )
+        ''');
 
-      // Create new tables with TEXT level_id
-      await db.execute('''
-        CREATE TABLE completed_levels_new (
-          level_id TEXT PRIMARY KEY,
-          completed_at TEXT NOT NULL,
-          moves_count INTEGER NOT NULL
-        )
-      ''');
-
-      await db.execute('''
-        CREATE TABLE saved_games_new (
-          level_id TEXT PRIMARY KEY,
-          board_state TEXT NOT NULL,
-          moves_count INTEGER NOT NULL,
-          saved_at TEXT NOT NULL
-        )
-      ''');
-
-      // Migrate data, converting level 0 to 0.1
-      await db.execute('''
-        INSERT INTO completed_levels_new (level_id, completed_at, moves_count)
-        SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
-               completed_at, moves_count
-        FROM completed_levels
-      ''');
-
-      await db.execute('''
-        INSERT INTO saved_games_new (level_id, board_state, moves_count, saved_at)
-        SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
-               board_state, moves_count, saved_at
-        FROM saved_games
-      ''');
-
-      // Drop old tables and rename new ones
-      await db.execute('DROP TABLE completed_levels');
-      await db.execute('DROP TABLE saved_games');
-      await db.execute('ALTER TABLE completed_levels_new RENAME TO completed_levels');
-      await db.execute('ALTER TABLE saved_games_new RENAME TO saved_games');
+        await txn.execute('''
+          CREATE TABLE saved_games_new (
+            level_id TEXT PRIMARY KEY,
+            board_state TEXT NOT NULL,
+            moves_count INTEGER NOT NULL,
+            saved_at TEXT NOT NULL
+          )
+        ''');
+
+        // Migrate data, converting level 0 to 0.1
+        await txn.execute('''
+          INSERT INTO completed_levels_new (level_id, completed_at, moves_count)
+          SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
+                 completed_at, moves_count
+          FROM completed_levels
+        ''');
+
+        await txn.execute('''
+          INSERT INTO saved_games_new (level_id, board_state, moves_count, saved_at)
+          SELECT CASE WHEN level_id = 0 THEN '0.1' ELSE CAST(level_id AS TEXT) END,
+                 board_state, moves_count, saved_at
+          FROM saved_games
+        ''');
+
+        // Drop old tables and rename new ones
+        await txn.execute('DROP TABLE completed_levels');
+        await txn.execute('DROP TABLE saved_games');
+        await txn.execute('ALTER TABLE completed_levels_new RENAME TO completed_levels');
+        await txn.execute('ALTER TABLE saved_games_new RENAME TO saved_games');
+      });
     }
🤖 Prompt for AI Agents
In `@lib/core/database/database_helper.dart` around lines 66 - 108, The migration
block guarded by "if (oldVersion < 6)" must be executed inside a database
transaction to avoid partial failure: replace the current sequence of await
db.execute(...) calls for creating completed_levels_new, saved_games_new, the
INSERT ... SELECT migrations, DROP TABLE completed_levels/saved_games and the
ALTER TABLE ... RENAME operations with a single transactional call (e.g. await
db.transaction((txn) async { ... }) ) and run each DDL/DML against the
transaction handle (txn.execute/txn.rawInsert) so any exception triggers an
automatic rollback and prevents loss of completed_levels and saved_games data.

}

Future<void> _createDB(Database db, int version) async {
// Table for completed levels
await db.execute('''
CREATE TABLE completed_levels (
level_id INTEGER PRIMARY KEY,
level_id TEXT PRIMARY KEY,
completed_at TEXT NOT NULL,
moves_count INTEGER NOT NULL
)
Expand All @@ -77,7 +121,7 @@ class DatabaseHelper {
// Table for saved game states
await db.execute('''
CREATE TABLE saved_games (
level_id INTEGER PRIMARY KEY,
level_id TEXT PRIMARY KEY,
board_state TEXT NOT NULL,
moves_count INTEGER NOT NULL,
saved_at TEXT NOT NULL
Expand All @@ -87,7 +131,7 @@ class DatabaseHelper {

// Completed Levels
Future<void> markLevelCompleted({
required int levelId,
required String levelId,
required int movesCount,
}) async {
final db = await database;
Expand All @@ -98,7 +142,7 @@ class DatabaseHelper {
}, conflictAlgorithm: ConflictAlgorithm.replace);
}

Future<bool> isLevelCompleted(int levelId) async {
Future<bool> isLevelCompleted(String levelId) async {
final db = await database;
final result = await db.query(
'completed_levels',
Expand All @@ -108,15 +152,15 @@ class DatabaseHelper {
return result.isNotEmpty;
}

Future<List<int>> getCompletedLevelIds() async {
Future<List<String>> getCompletedLevelIds() async {
final db = await database;
final result = await db.query('completed_levels');
return result.map((row) => row['level_id'] as int).toList();
return result.map((row) => row['level_id'] as String).toList();
}

// Saved Game States
Future<void> saveGameState({
required int levelId,
required String levelId,
required List<List<String>> board,
required int movesCount,
}) async {
Expand All @@ -129,7 +173,7 @@ class DatabaseHelper {
}, conflictAlgorithm: ConflictAlgorithm.replace);
}

Future<Map<String, dynamic>?> getSavedGameState(int levelId) async {
Future<Map<String, dynamic>?> getSavedGameState(String levelId) async {
final db = await database;
final result = await db.query(
'saved_games',
Expand All @@ -142,14 +186,14 @@ class DatabaseHelper {
try {
final row = result.first;
final boardData = jsonDecode(row['board_state'] as String) as List<dynamic>;

// Validate board structure
if (boardData.isEmpty || boardData.first is! List) {
// Invalid board structure, delete corrupted save
await deleteSavedGameState(levelId);
return null;
}

return {
'board': boardData,
'moves_count': row['moves_count'] as int,
Expand All @@ -161,7 +205,7 @@ class DatabaseHelper {
}
}

Future<void> deleteSavedGameState(int levelId) async {
Future<void> deleteSavedGameState(String levelId) async {
final db = await database;
await db.delete('saved_games', where: 'level_id = ?', whereArgs: [levelId]);
}
Expand All @@ -172,7 +216,7 @@ class DatabaseHelper {
}

/// Clear saved games for specific levels (useful after level modifications)
Future<void> clearSavedGamesForLevels(List<int> levelIds) async {
Future<void> clearSavedGamesForLevels(List<String> levelIds) async {
if (levelIds.isEmpty) return;
final db = await database;
final placeholders = List.filled(levelIds.length, '?').join(',');
Expand Down
2 changes: 1 addition & 1 deletion lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class AppRouter {
path: '$game/:levelId',
name: game,
pageBuilder: (context, state) {
final levelId = int.parse(state.pathParameters['levelId']!);
final levelId = state.pathParameters['levelId']!;
return _fadeTransition(GameScreen(levelId: levelId), state);
},
),
Expand Down
2 changes: 1 addition & 1 deletion lib/core/utils/market_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ class MarketHelper {
static String _getCurrentOtherAppsUrl() {
// Check which URL is currently active
// The Makefile will modify this to switch between markets
return GeneralConsts.otherAppsRustoreLink; // This will be changed by make
return GeneralConsts.otherAppsGooglePlayLink; // This will be changed by make
Comment on lines 41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Makefile/mk scripts still target the correct token after this change
echo "=== Searching for Makefile/mk files ==="
fd -e Makefile -e mk | head -20

echo -e "\n=== Searching in Makefiles for market-related tokens ==="
rg -n "market_helper|otherAppsGooglePlayLink|otherAppsRuStoreLink|rustore|play\.google\.com" -g 'Makefile*' -g '*.mk' --max-count=20

echo -e "\n=== Checking market_helper.dart constants ==="
rg -n "otherAppsGooglePlayLink|otherAppsRuStoreLink|RuStore" lib/core/utils/market_helper.dart

Repository: khlebobul/pegma

Length of output: 2602


Update the Makefile sed pattern for Google Play builds to match the new default.

The set-google-play target (line 33) uses:

sed -i '' -E "s|return GeneralConsts\\.otherAppsRustoreLink;|return GeneralConsts.otherAppsGooglePlayLink;|"

This no longer matches the default return statement after your change. While RuStore builds work correctly (line 39), the Google Play build logic is broken—relying on the code already being at the correct default. Update line 33 to replace otherAppsGooglePlayLink when needed, or adjust the default logic to ensure both build paths work reliably.

🤖 Prompt for AI Agents
In `@lib/core/utils/market_helper.dart` around lines 41 - 44, The Makefile's
set-google-play sed pattern no longer matches because the code default was
changed to return GeneralConsts.otherAppsGooglePlayLink; update the sed in the
set-google-play target so it matches either variant and forces the return to
GeneralConsts.otherAppsGooglePlayLink (e.g. use a regex that matches
GeneralConsts.otherAppsRustoreLink|GeneralConsts.otherAppsGooglePlayLink and
replaces with GeneralConsts.otherAppsGooglePlayLink); also ensure the
set-rustore target is updated symmetrically to always replace either symbol with
GeneralConsts.otherAppsRustoreLink so _getCurrentOtherAppsUrl and the
GeneralConsts.otherAppsGooglePlayLink/otherAppsRustoreLink swaps work reliably.

}
}
File renamed without changes.
11 changes: 11 additions & 0 deletions lib/data/levels/level_0.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"board": [
["x", "x", "1", "1", "1", "x", "x"],
["x", "1", "1", "1", "1", "1", "x"],
["1", "1", "1", "1", "1", "1", "1"],
["1", "1", "1", "0", "1", "1", "1"],
["1", "1", "1", "1", "1", "1", "1"],
["x", "1", "1", "1", "1", "1", "x"],
["x", "x", "1", "1", "1", "x", "x"]
]
}
4 changes: 2 additions & 2 deletions lib/presentation/providers/completed_levels_provider.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pegma/core/database/database_helper.dart';

final completedLevelsProvider = FutureProvider<List<int>>((ref) async {
final completedLevelsProvider = FutureProvider<List<String>>((ref) async {
final db = DatabaseHelper.instance;
return await db.getCompletedLevelIds();
});

final isLevelCompletedProvider = FutureProvider.family<bool, int>((
final isLevelCompletedProvider = FutureProvider.family<bool, String>((
ref,
levelId,
) async {
Expand Down
4 changes: 2 additions & 2 deletions lib/presentation/providers/game_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Game extends _$Game {
final DatabaseHelper _db = DatabaseHelper.instance;

@override
GameState build(int levelId) {
GameState build(String levelId) {
ref.keepAlive();
return GameState(board: <List<String>>[], possibleMoves: []);
}
Expand Down Expand Up @@ -50,7 +50,7 @@ class Game extends _$Game {
return LevelLoadType.fresh;
}

Future<void> loadLevel(int level, {bool ignoreSaved = false}) async {
Future<void> loadLevel(String level, {bool ignoreSaved = false}) async {
try {
Map<String, dynamic>? savedState;

Expand Down
14 changes: 7 additions & 7 deletions lib/presentation/providers/game_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 11 additions & 6 deletions lib/presentation/providers/levels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final levelsProvider = FutureProvider<List<int>>((ref) async {
final levelsProvider = FutureProvider<List<String>>((ref) async {
final manifestContent = await rootBundle.loadString('AssetManifest.json');
final Map<String, dynamic> manifestMap = json.decode(manifestContent);

Expand All @@ -11,11 +11,16 @@ final levelsProvider = FutureProvider<List<int>>((ref) async {
.toList();

final levels = levelPaths.map((path) {
final levelNumber = int.parse(
path.split('/').last.replaceAll('level_', '').replaceAll('.json', ''),
);
return levelNumber;
}).toList()..sort();
final levelId = path.split('/').last.replaceAll('level_', '').replaceAll('.json', '');
return levelId;
}).toList();

// Sort numerically (supports fractional levels like 0.1, 0.2)
levels.sort((a, b) {
final aNum = double.tryParse(a) ?? 0;
final bNum = double.tryParse(b) ?? 0;
return aNum.compareTo(bNum);
});

return levels;
});
Loading