Skip to content
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ cp Manager/.env_template Manager/.env

---

## 🔢 Versioning Rule (Main Merge)

Every merge into `main` **must** bump the app "major" version by **+1**.

For this repo, while versions are still in `0.x.y`, we treat the **`x`** as the
major version. That means **`0.x → 0.(x+1)`** on every merge.

Update both of these files before merging:

- `Kiosk/pubspec.yaml`
- `Manager/pubspec.yaml`

Example:

```
0.1.3+4 → 0.2.0+1
```

---

## 🧾 Commit Style

Keep commits small and meaningful. Suggested format:
Expand Down
2 changes: 2 additions & 0 deletions Kiosk/README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Kiosk/assets/branding/secgo-kiosk-icon.base64

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions Kiosk/integration_test/app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:kiosk/screens/pin_setup_screen.dart';
import 'package:kiosk/services/settings_service.dart';
import 'package:kiosk/l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Expand All @@ -20,6 +21,11 @@ void main() {
if (!Hive.isAdapterRegistered(0)) {
Hive.registerAdapter(ProductAdapter());
}
try {
await dotenv.load(fileName: '.env');
} catch (e) {
debugPrint('Warning: .env not loaded: $e');
}
settingsService = SettingsService();
await settingsService.init();
});
Expand Down
27 changes: 20 additions & 7 deletions Kiosk/lib/db/database_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ class DatabaseHelper {
return maps.map((json) => Product.fromJson(json)).toList();
}

Future<void> clearProducts() async {
final db = await instance.database;
await db.delete('products');
}

// Order Methods
Future<void> insertOrder(Order order) async {
final db = await instance.database;
Expand All @@ -112,24 +117,32 @@ class DatabaseHelper {
// Note: We use raw SQL to attach because sqflite doesn't support DETACH via API directly easily
// But standard SQL 'ATTACH DATABASE' works.

var attached = false;
try {
await db.execute("ATTACH DATABASE '$backupPath' AS backup_db");
attached = true;

final tables = await db.rawQuery(
"SELECT name FROM backup_db.sqlite_master WHERE type='table' AND name='products'",
);
if (tables.isEmpty) {
throw Exception('Backup missing products table');
}

// Clear current products
await db.execute("DELETE FROM products");

// Insert products from backup
// Assuming backup_db has 'products' table with same schema
await db.execute("INSERT INTO products SELECT * FROM backup_db.products");

// Detach
await db.execute("DETACH DATABASE backup_db");
} catch (e) {
// Attempt detach in case of error to avoid lock
try {
await db.execute("DETACH DATABASE backup_db");
} catch (_) {}
rethrow;
} finally {
if (attached) {
try {
await db.execute("DETACH DATABASE backup_db");
} catch (_) {}
}
}
}
}
2 changes: 1 addition & 1 deletion Kiosk/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@
"retry": "Retry",
"serverNoIp": "Failed to start server: No IP address found",
"restoreComplete": "Restore Complete",
"returningHome": "Returning to the start screen..."
"returningHome": "Refreshing data..."
}
2 changes: 1 addition & 1 deletion Kiosk/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ abstract class AppLocalizations {
/// No description provided for @returningHome.
///
/// In en, this message translates to:
/// **'Returning to the start screen...'**
/// **'Refreshing data...'**
String get returningHome;
}

Expand Down
2 changes: 1 addition & 1 deletion Kiosk/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,5 @@ class AppLocalizationsEn extends AppLocalizations {
String get restoreComplete => 'Restore Complete';

@override
String get returningHome => 'Returning to the start screen...';
String get returningHome => 'Refreshing data...';
}
2 changes: 1 addition & 1 deletion Kiosk/lib/l10n/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,5 +171,5 @@ class AppLocalizationsZh extends AppLocalizations {
String get restoreComplete => '恢复完成';

@override
String get returningHome => '正在返回初始界面...';
String get returningHome => '正在刷新数据...';
}
2 changes: 1 addition & 1 deletion Kiosk/lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"retry": "重试",
"serverNoIp": "启动服务失败:未找到IP地址",
"restoreComplete": "恢复完成",
"returningHome": "正在返回初始界面...",
"returningHome": "正在刷新数据...",
"setupPin": "设置管理员PIN码",
"setAdminPin": "设置管理员PIN码",
"pinDescription": "此PIN码将用于访问设置和连接管理应用。",
Expand Down
21 changes: 21 additions & 0 deletions Kiosk/lib/screens/main_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:kiosk/screens/settings_screen.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:kiosk/l10n/app_localizations.dart';
import 'package:kiosk/config/store_config.dart';
import 'package:kiosk/services/restore_notifier.dart';

// Helper class for cart items
class CartItem {
Expand Down Expand Up @@ -83,6 +84,7 @@ class _MainScreenState extends State<MainScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
final Map<String, CartItem> _cartItems = {}; // Use Map for O(1) lookups
bool _isProcessing = false;
final RestoreNotifier _restoreNotifier = RestoreNotifier.instance;

// Use the front camera as requested.
final MobileScannerController _scannerController = MobileScannerController(
Expand All @@ -106,9 +108,16 @@ class _MainScreenState extends State<MainScreen> {
returnImage: false, // Improves performance on older devices
);

@override
void initState() {
super.initState();
_restoreNotifier.addListener(_handleRestore);
}

@override
void dispose() {
_scannerController.dispose();
_restoreNotifier.removeListener(_handleRestore);
super.dispose();
}

Expand Down Expand Up @@ -143,6 +152,18 @@ class _MainScreenState extends State<MainScreen> {
});
}

void _handleRestore() {
if (!mounted) return;
_clearCart();
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.restoreComplete),
duration: const Duration(seconds: 2),
),
);
}

Future<void> _handleBarcodeDetect(BarcodeCapture capture) async {
// Only process logic if not already processing
if (_isProcessing) return;
Expand Down
8 changes: 3 additions & 5 deletions Kiosk/lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
import 'package:kiosk/services/server/kiosk_server.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:kiosk/l10n/app_localizations.dart';
import 'package:kiosk/screens/main_screen.dart';

import 'package:kiosk/services/settings_service.dart';
import 'package:kiosk/services/restore_notifier.dart';

class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
Expand Down Expand Up @@ -51,13 +51,11 @@ class _SettingsScreenState extends State<SettingsScreen> {

Future<void> _runRestoreCompleteFlow() async {
if (!mounted) return;
RestoreNotifier.instance.notifyRestored();
setState(() => _showRestoreComplete = true);
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const MainScreen()),
(route) => false,
);
setState(() => _showRestoreComplete = false);
}

Future<void> _startServer() async {
Expand Down
9 changes: 9 additions & 0 deletions Kiosk/lib/services/restore_notifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';

class RestoreNotifier extends ChangeNotifier {
RestoreNotifier._internal();

static final RestoreNotifier instance = RestoreNotifier._internal();

void notifyRestored() => notifyListeners();
}
68 changes: 57 additions & 11 deletions Kiosk/lib/services/server/kiosk_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,14 @@ class KioskServerService {
// Endpoint: Sync Products (Push from Manager)
router.post('/sync/products', (Request request) async {
try {
final syncMode = request.headers['X-Sync-Mode']?.toLowerCase();
final payload = await request.readAsString();
final List<dynamic> productsJson = jsonDecode(payload);
final List<dynamic> productsJson =
payload.isNotEmpty ? jsonDecode(payload) as List<dynamic> : <dynamic>[];

if (syncMode == 'replace') {
await DatabaseHelper.instance.clearProducts();
}

for (var p in productsJson) {
await DatabaseHelper.instance.upsertProduct(Product.fromJson(p));
Expand Down Expand Up @@ -170,21 +176,61 @@ class KioskServerService {
// Endpoint: Get Backup (Download DB)
router.get('/backup', (Request request) async {
try {
debugPrint('Backup request received');
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'kiosk.db');
final file = File(path);
if (await file.exists()) {
return Response.ok(
file.openRead(),
headers: {
'Content-Type': 'application/x-sqlite3',
'Content-Disposition': 'attachment; filename="kiosk.db"'
}
);
final dbFile = File(join(dbPath, 'kiosk.db'));
if (await dbFile.exists()) {
final backupFile = File(join(dbPath, 'kiosk_backup_export.db'));
if (await backupFile.exists()) {
await backupFile.delete();
}

final db = await DatabaseHelper.instance.database;
var useVacuum = false;
try {
await db.execute("VACUUM INTO '${backupFile.path}'");
useVacuum = true;
debugPrint('Backup created via VACUUM INTO');
} catch (e) {
debugPrint('Backup VACUUM failed, falling back to checkpoint: $e');
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
}

var fileToSend = useVacuum ? backupFile : dbFile;
if (useVacuum && await fileToSend.length() == 0) {
fileToSend = dbFile;
useVacuum = false;
debugPrint('VACUUM backup empty, sending main db');
}

debugPrint('Backup file size: ${await fileToSend.length()} bytes');
final streamController = StreamController<List<int>>();
fileToSend.openRead().listen(
streamController.add,
onError: streamController.addError,
onDone: () async {
if (useVacuum) {
try {
await backupFile.delete();
} catch (_) {}
}
await streamController.close();
},
cancelOnError: true,
);

return Response.ok(
streamController.stream,
headers: {
'Content-Type': 'application/x-sqlite3',
'Content-Disposition': 'attachment; filename="kiosk.db"'
},
);
} else {
return Response.notFound('Database file not found');
}
} catch (e) {
debugPrint('Backup failed: $e');
return Response.internalServerError(body: 'Backup failed: $e');
}
});
Expand Down
2 changes: 2 additions & 0 deletions Manager/README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Manager/assets/branding/secgo-manager-icon.base64

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions Manager/lib/db/database_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ class DatabaseHelper {
return maps.map((json) => Product.fromJson(json)).toList();
}

Future<int> deleteProduct(String barcode) async {
final db = await instance.database;
return await db.delete(
'products',
where: 'barcode = ?',
whereArgs: [barcode],
);
}

// Kiosk Methods
Future<int> insertKiosk(Kiosk kiosk) async {
final db = await instance.database;
Expand Down
16 changes: 15 additions & 1 deletion Manager/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"noBackupsFound": "No backups found",
"backupCreated": "Backup created successfully",
"backupCreateFailed": "Failed to create backup",
"backupCreateFailedWithReason": "Backup failed: {reason}",
"confirmRestore": "Confirm Restore",
"restoreOverwriteWarning": "This will overwrite the products on the Kiosk with this backup. Orders will be preserved. Continue?",
"restore": "Restore",
Expand All @@ -62,5 +63,18 @@
"pairingKiosk": "Pairing with kiosk at {ip}...",
"pairFailed": "Pairing failed",
"pairSuccess": "Kiosk paired successfully",
"scanKioskHint": "Scan the QR code on the Kiosk Settings screen"
"scanKioskHint": "Scan the QR code on the Kiosk Settings screen",
"delete": "Delete",
"deleteProductTitle": "Delete Product",
"deleteProductMessage": "Delete {name}?",
"deleteSuccess": "Product deleted",
"deleteFailed": "Failed to delete product",
"deleteSyncFailed": "Product deleted, but sync to kiosk failed.",
"connectKioskToUpload": "Connect to a kiosk to upload the QR code.",
"restoreFailedWithReason": "Restore failed: {reason}",
"enterPinPrompt": "Enter PIN to pair",
"enterPinTitle": "Enter PIN",
"enterPinHint": "Kiosk PIN",
"pinLength": "PIN must be at least 4 digits",
"confirm": "Confirm"
}
Loading