diff --git a/deps.txt b/deps.txt new file mode 100644 index 0000000..3ac036c Binary files /dev/null and b/deps.txt differ diff --git a/lib/app/app.dart b/lib/app/app.dart index 29c1f66..69d4000 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,23 +1,51 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:kooltab2/theme.dart'; +import 'package:kooltab2/utils/app_update_util.dart'; -class KoolTabApp extends StatelessWidget { - const KoolTabApp({ - super.key, - required this.router, - }); +class KoolTabApp extends StatefulWidget { + const KoolTabApp({super.key, required this.router}); final GoRouter router; + @override + State createState() => _KoolTabAppState(); +} + +class _KoolTabAppState extends State { + @override + void initState() { + super.initState(); + + _checkForUpdates(); + } + + Future _checkForUpdates() async { + final updater = AppUpdateUtil(serverUrl: "http://localhost:3000/"); + + try { + final update = await updater.checkForUpdate(); + + if (update == null) { + return; + } + + debugPrint("Update available: ${update.version}"); + + // TODO: + // Show update dialog here + } catch (e) { + debugPrint("Update check failed: $e"); + } + } + @override Widget build(BuildContext context) { return MaterialApp.router( title: 'KoolTab', debugShowCheckedModeBanner: false, - routerConfig: router, - // theme: lightTheme, + routerConfig: widget.router, theme: darkTheme, ); } -} \ No newline at end of file +} diff --git a/lib/app/router.dart b/lib/app/router.dart index cb3ee29..f520e61 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:kooltab2/views/settings_view.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../views/bar_screen_view.dart'; @@ -31,6 +32,10 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { return null; }, routes: [ + GoRoute( + path: '/', + redirect: (context, state) => '/bar' + ), GoRoute( path: '/lock', builder: (context, state) => PinEntryView( @@ -67,6 +72,11 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { path: '/history', builder: (context, state) => const HistoryScreenView(), ), + + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreenView(), + ), ], ); } diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index da7b855..7f0f9a1 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -93,6 +93,19 @@ class ClosedTabItems extends Table { Set get primaryKey => {id}; } +@DataClassName('AppSettingsRow') +class AppSettingsTable extends Table { + @override + String get tableName => 'app_settings'; + + TextColumn get id => text()(); + BoolColumn get pinRequired => boolean().withDefault(const Constant(false))(); + TextColumn get themeMode => text().withDefault(const Constant('system'))(); + + @override + Set get primaryKey => {id}; +} + @DriftDatabase( tables: [ Products, @@ -100,7 +113,9 @@ class ClosedTabItems extends Table { TabItems, ClosedTabs, - ClosedTabItems + ClosedTabItems, + + AppSettingsTable ], ) class AppDatabase extends _$AppDatabase { @@ -129,6 +144,10 @@ class AppDatabase extends _$AppDatabase { await migrator.createTable(closedTabs); await migrator.createTable(closedTabItems); } + + if (from < 5){ + await migrator.createTable(appSettingsTable); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 6fc833f..6795b93 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2126,6 +2126,271 @@ class ClosedTabItemsCompanion extends UpdateCompanion { } } +class $AppSettingsTableTable extends AppSettingsTable + with TableInfo<$AppSettingsTableTable, AppSettingsRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AppSettingsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _pinRequiredMeta = const VerificationMeta( + 'pinRequired', + ); + @override + late final GeneratedColumn pinRequired = GeneratedColumn( + 'pin_required', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("pin_required" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _themeModeMeta = const VerificationMeta( + 'themeMode', + ); + @override + late final GeneratedColumn themeMode = GeneratedColumn( + 'theme_mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('system'), + ); + @override + List get $columns => [id, pinRequired, themeMode]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'app_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('pin_required')) { + context.handle( + _pinRequiredMeta, + pinRequired.isAcceptableOrUnknown( + data['pin_required']!, + _pinRequiredMeta, + ), + ); + } + if (data.containsKey('theme_mode')) { + context.handle( + _themeModeMeta, + themeMode.isAcceptableOrUnknown(data['theme_mode']!, _themeModeMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AppSettingsRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AppSettingsRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + pinRequired: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}pin_required'], + )!, + themeMode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_mode'], + )!, + ); + } + + @override + $AppSettingsTableTable createAlias(String alias) { + return $AppSettingsTableTable(attachedDatabase, alias); + } +} + +class AppSettingsRow extends DataClass implements Insertable { + final String id; + final bool pinRequired; + final String themeMode; + const AppSettingsRow({ + required this.id, + required this.pinRequired, + required this.themeMode, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['pin_required'] = Variable(pinRequired); + map['theme_mode'] = Variable(themeMode); + return map; + } + + AppSettingsTableCompanion toCompanion(bool nullToAbsent) { + return AppSettingsTableCompanion( + id: Value(id), + pinRequired: Value(pinRequired), + themeMode: Value(themeMode), + ); + } + + factory AppSettingsRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AppSettingsRow( + id: serializer.fromJson(json['id']), + pinRequired: serializer.fromJson(json['pinRequired']), + themeMode: serializer.fromJson(json['themeMode']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'pinRequired': serializer.toJson(pinRequired), + 'themeMode': serializer.toJson(themeMode), + }; + } + + AppSettingsRow copyWith({String? id, bool? pinRequired, String? themeMode}) => + AppSettingsRow( + id: id ?? this.id, + pinRequired: pinRequired ?? this.pinRequired, + themeMode: themeMode ?? this.themeMode, + ); + AppSettingsRow copyWithCompanion(AppSettingsTableCompanion data) { + return AppSettingsRow( + id: data.id.present ? data.id.value : this.id, + pinRequired: data.pinRequired.present + ? data.pinRequired.value + : this.pinRequired, + themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, + ); + } + + @override + String toString() { + return (StringBuffer('AppSettingsRow(') + ..write('id: $id, ') + ..write('pinRequired: $pinRequired, ') + ..write('themeMode: $themeMode') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, pinRequired, themeMode); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AppSettingsRow && + other.id == this.id && + other.pinRequired == this.pinRequired && + other.themeMode == this.themeMode); +} + +class AppSettingsTableCompanion extends UpdateCompanion { + final Value id; + final Value pinRequired; + final Value themeMode; + final Value rowid; + const AppSettingsTableCompanion({ + this.id = const Value.absent(), + this.pinRequired = const Value.absent(), + this.themeMode = const Value.absent(), + this.rowid = const Value.absent(), + }); + AppSettingsTableCompanion.insert({ + required String id, + this.pinRequired = const Value.absent(), + this.themeMode = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? pinRequired, + Expression? themeMode, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (pinRequired != null) 'pin_required': pinRequired, + if (themeMode != null) 'theme_mode': themeMode, + if (rowid != null) 'rowid': rowid, + }); + } + + AppSettingsTableCompanion copyWith({ + Value? id, + Value? pinRequired, + Value? themeMode, + Value? rowid, + }) { + return AppSettingsTableCompanion( + id: id ?? this.id, + pinRequired: pinRequired ?? this.pinRequired, + themeMode: themeMode ?? this.themeMode, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (pinRequired.present) { + map['pin_required'] = Variable(pinRequired.value); + } + if (themeMode.present) { + map['theme_mode'] = Variable(themeMode.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AppSettingsTableCompanion(') + ..write('id: $id, ') + ..write('pinRequired: $pinRequired, ') + ..write('themeMode: $themeMode, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -2134,6 +2399,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $TabItemsTable tabItems = $TabItemsTable(this); late final $ClosedTabsTable closedTabs = $ClosedTabsTable(this); late final $ClosedTabItemsTable closedTabItems = $ClosedTabItemsTable(this); + late final $AppSettingsTableTable appSettingsTable = $AppSettingsTableTable( + this, + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2144,6 +2412,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { tabItems, closedTabs, closedTabItems, + appSettingsTable, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -3677,6 +3946,176 @@ typedef $$ClosedTabItemsTableProcessedTableManager = ClosedTabItemRow, PrefetchHooks Function() >; +typedef $$AppSettingsTableTableCreateCompanionBuilder = + AppSettingsTableCompanion Function({ + required String id, + Value pinRequired, + Value themeMode, + Value rowid, + }); +typedef $$AppSettingsTableTableUpdateCompanionBuilder = + AppSettingsTableCompanion Function({ + Value id, + Value pinRequired, + Value themeMode, + Value rowid, + }); + +class $$AppSettingsTableTableFilterComposer + extends Composer<_$AppDatabase, $AppSettingsTableTable> { + $$AppSettingsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pinRequired => $composableBuilder( + column: $table.pinRequired, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnFilters(column), + ); +} + +class $$AppSettingsTableTableOrderingComposer + extends Composer<_$AppDatabase, $AppSettingsTableTable> { + $$AppSettingsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pinRequired => $composableBuilder( + column: $table.pinRequired, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$AppSettingsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $AppSettingsTableTable> { + $$AppSettingsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get pinRequired => $composableBuilder( + column: $table.pinRequired, + builder: (column) => column, + ); + + GeneratedColumn get themeMode => + $composableBuilder(column: $table.themeMode, builder: (column) => column); +} + +class $$AppSettingsTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $AppSettingsTableTable, + AppSettingsRow, + $$AppSettingsTableTableFilterComposer, + $$AppSettingsTableTableOrderingComposer, + $$AppSettingsTableTableAnnotationComposer, + $$AppSettingsTableTableCreateCompanionBuilder, + $$AppSettingsTableTableUpdateCompanionBuilder, + ( + AppSettingsRow, + BaseReferences< + _$AppDatabase, + $AppSettingsTableTable, + AppSettingsRow + >, + ), + AppSettingsRow, + PrefetchHooks Function() + > { + $$AppSettingsTableTableTableManager( + _$AppDatabase db, + $AppSettingsTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AppSettingsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AppSettingsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AppSettingsTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value pinRequired = const Value.absent(), + Value themeMode = const Value.absent(), + Value rowid = const Value.absent(), + }) => AppSettingsTableCompanion( + id: id, + pinRequired: pinRequired, + themeMode: themeMode, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value pinRequired = const Value.absent(), + Value themeMode = const Value.absent(), + Value rowid = const Value.absent(), + }) => AppSettingsTableCompanion.insert( + id: id, + pinRequired: pinRequired, + themeMode: themeMode, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$AppSettingsTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $AppSettingsTableTable, + AppSettingsRow, + $$AppSettingsTableTableFilterComposer, + $$AppSettingsTableTableOrderingComposer, + $$AppSettingsTableTableAnnotationComposer, + $$AppSettingsTableTableCreateCompanionBuilder, + $$AppSettingsTableTableUpdateCompanionBuilder, + ( + AppSettingsRow, + BaseReferences<_$AppDatabase, $AppSettingsTableTable, AppSettingsRow>, + ), + AppSettingsRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -3691,4 +4130,6 @@ class $AppDatabaseManager { $$ClosedTabsTableTableManager(_db, _db.closedTabs); $$ClosedTabItemsTableTableManager get closedTabItems => $$ClosedTabItemsTableTableManager(_db, _db.closedTabItems); + $$AppSettingsTableTableTableManager get appSettingsTable => + $$AppSettingsTableTableTableManager(_db, _db.appSettingsTable); } diff --git a/lib/main.dart b/lib/main.dart index 8709f17..232a5b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,8 +3,10 @@ import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:kooltab2/services/pin_lock_service.dart'; +import 'package:kooltab2/services/settings_service.dart'; import 'package:kooltab2/viewmodels/history_view_model.dart'; import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; +import 'package:kooltab2/viewmodels/settings_view_model.dart'; import 'package:provider/provider.dart'; import 'app/app.dart'; @@ -40,14 +42,16 @@ Future main() async { dispose: (_, database) => database.close(), ), Provider( - create: (context) => DriftProductService( - database: context.read(), - ), + create: (context) => + DriftProductService(database: context.read()), ), Provider( - create: (context) => DriftBarTabService( - database: context.read(), - ), + create: (context) => + DriftBarTabService(database: context.read()), + ), + Provider( + create: (context) => + DriftSettingsService(database: context.read()), ), ChangeNotifierProvider( create: (context) => ProductListViewModel( @@ -55,21 +59,21 @@ Future main() async { ), ), ChangeNotifierProvider( - create: (context) => BarScreenViewModel( - barTabService: context.read(), - ), + create: (context) => + BarScreenViewModel(barTabService: context.read(), productService: context.read()), ), ChangeNotifierProvider( create: (context) => HistoryViewModel(barTabService: context.read()), ), - ChangeNotifierProvider.value( - value: pinLockViewModel, + ChangeNotifierProvider.value(value: pinLockViewModel), + ChangeNotifierProvider( + create: (context) => SettingsViewModel( + settingsService: context.read(), + ), ), ], - child: AppBootstrap( - child: KoolTabApp(router: appRouter), - ), + child: AppBootstrap(child: KoolTabApp(router: appRouter)), ), ); -} \ No newline at end of file +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart new file mode 100644 index 0000000..5c12b29 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,26 @@ +enum AppThemeMode { system, light, dark } + +class AppSettings { + final bool pinRequired; + final AppThemeMode themeMode; + + const AppSettings({ + required this.pinRequired, + required this.themeMode, + }); + + AppSettings copyWith({ + bool? pinRequired, + AppThemeMode? themeMode, + }) { + return AppSettings( + pinRequired: pinRequired ?? this.pinRequired, + themeMode: themeMode ?? this.themeMode, + ); + } + + static const AppSettings defaults = AppSettings( + pinRequired: false, + themeMode: AppThemeMode.system, + ); +} \ No newline at end of file diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 2db5b7a..e010098 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -231,7 +231,6 @@ class DriftBarTabService implements BarTabService { final items = await _getItemsForTab(tabId); if (items.isEmpty) { - // Nothing to settle, leave the tab as-is. return; } @@ -263,9 +262,6 @@ class DriftBarTabService implements BarTabService { ..where((item) => item.tabId.equals(tabId)); await deleteQuery.go(); - - // Note: tab status/customerName untouched on purpose — the tab - // stays open so it keeps showing in the open tabs list. }); } diff --git a/lib/services/pin_lock_service.dart b/lib/services/pin_lock_service.dart index fa7aa32..1c875a2 100644 --- a/lib/services/pin_lock_service.dart +++ b/lib/services/pin_lock_service.dart @@ -4,12 +4,9 @@ import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -/// Stores and verifies a PIN. The raw PIN is never persisted — only a -/// salted SHA-256 hash of it, kept in secure storage (Keychain on iOS, -/// EncryptedSharedPreferences/Keystore on Android). class PinLockService { PinLockService({FlutterSecureStorage? storage}) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? const FlutterSecureStorage(); static const _saltKey = 'pin_salt'; static const _hashKey = 'pin_hash'; @@ -53,4 +50,4 @@ class PinLockService { final bytes = utf8.encode('$salt:$pin'); return sha256.convert(bytes).toString(); } -} \ No newline at end of file +} diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index dccb654..58ab766 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -21,6 +21,9 @@ abstract class ProductService { Future updateProduct(Product product); Future deleteProduct(String id); + + Future decreaseStock(String productId, int amount); + Future increaseStock(String productId, int amount); } class DriftProductService implements ProductService { @@ -118,4 +121,39 @@ class DriftProductService implements ProductService { await query.go(); } + + @override + Future decreaseStock(String productId, int amount) async { + final product = await getProductById(productId); + + if (product == null) { + throw Exception('Product not found'); + } + + if (product.stockQuantity < amount) { + throw Exception('Not enough stock'); + } + + await updateProduct( + product.copyWith( + stockQuantity: product.stockQuantity - amount, + ), + ); + } + + @override + Future increaseStock(String productId, int amount) async { + final product = await getProductById(productId); + + if (product == null) { + throw Exception('Product not found'); + } + + await updateProduct( + product.copyWith( + stockQuantity: product.stockQuantity + amount, + ), + ); + } + } \ No newline at end of file diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..5fe3963 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,50 @@ +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../models/settings.dart'; + +abstract class SettingsService { + Future getSettings(); + + Future saveSettings(AppSettings settings); +} + +class DriftSettingsService implements SettingsService { + final AppDatabase database; + static const _settingsId = 'app_settings'; + + DriftSettingsService({required this.database}); + + AppSettings _mapRowToSettings(AppSettingsRow row) { + return AppSettings( + pinRequired: row.pinRequired, + themeMode: AppThemeMode.values.firstWhere( + (mode) => mode.name == row.themeMode, + orElse: () => AppThemeMode.system, + ), + ); + } + + @override + Future getSettings() async { + final query = database.select(database.appSettingsTable) + ..where((s) => s.id.equals(_settingsId)); + + final row = await query.getSingleOrNull(); + + if (row == null) return AppSettings.defaults; + + return _mapRowToSettings(row); + } + + @override + Future saveSettings(AppSettings settings) async { + await database.into(database.appSettingsTable).insertOnConflictUpdate( + AppSettingsTableCompanion.insert( + id: _settingsId, + pinRequired: Value(settings.pinRequired), + themeMode: Value(settings.themeMode.name), + ), + ); + } +} \ No newline at end of file diff --git a/lib/theme.dart b/lib/theme.dart index e0a8a58..a237364 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -63,7 +63,7 @@ final ThemeData darkTheme = ThemeData( iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( - backgroundColor: Colors.white.withOpacity(0.05), + backgroundColor: Colors.white.withValues(alpha: 0.05), foregroundColor: Colors.white70, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadii.md), @@ -90,7 +90,7 @@ final ThemeData darkTheme = ThemeData( elevation: 0, backgroundColor: _violet, foregroundColor: Colors.white, - disabledBackgroundColor: Colors.white.withOpacity(0.06), + disabledBackgroundColor: Colors.white.withValues(alpha: 0.06), disabledForegroundColor: Colors.white24, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, @@ -195,7 +195,7 @@ final ThemeData darkTheme = ThemeData( navigationBarTheme: NavigationBarThemeData( backgroundColor: _surface, - indicatorColor: _violet.withOpacity(0.25), + indicatorColor: _violet.withValues(alpha: 0.25), labelTextStyle: const WidgetStatePropertyAll( TextStyle(fontWeight: FontWeight.w600), ), @@ -484,7 +484,7 @@ final ThemeData lightTheme = ThemeData( iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( - backgroundColor: Colors.black.withOpacity(0.04), + backgroundColor: Colors.black.withValues(alpha: 0.04), foregroundColor: Colors.black54, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadii.md), @@ -511,7 +511,7 @@ final ThemeData lightTheme = ThemeData( elevation: 0, backgroundColor: _violetL, foregroundColor: Colors.white, - disabledBackgroundColor: Colors.black.withOpacity(0.06), + disabledBackgroundColor: Colors.black.withValues(alpha: 0.06), disabledForegroundColor: Colors.black26, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, @@ -616,7 +616,7 @@ final ThemeData lightTheme = ThemeData( navigationBarTheme: NavigationBarThemeData( backgroundColor: _surfaceL, - indicatorColor: _violetL.withOpacity(0.15), + indicatorColor: _violetL.withValues(alpha: 0.15), labelTextStyle: const WidgetStatePropertyAll( TextStyle(fontWeight: FontWeight.w600), ), diff --git a/lib/utils/app_update_util.dart b/lib/utils/app_update_util.dart new file mode 100644 index 0000000..0cb7191 --- /dev/null +++ b/lib/utils/app_update_util.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +// import 'package:open_filex/open_filex.dart'; + +// import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + + +class UpdateInfo { + final bool update; + final String version; + final String notes; + final bool mandatory; + final String sha256; + final String download; + + + UpdateInfo({ + required this.update, + required this.version, + required this.notes, + required this.mandatory, + required this.sha256, + required this.download, + }); + + + factory UpdateInfo.fromJson(Map json) { + return UpdateInfo( + update: json["update"] ?? false, + version: json["version"] ?? "", + notes: json["notes"] ?? "", + mandatory: json["mandatory"] ?? false, + sha256: json["sha256"] ?? "", + download: json["download"] ?? "", + ); + } +} + + +class AppUpdateUtil { + + + final String serverUrl; + + + AppUpdateUtil({ + required this.serverUrl, + }); + + + /// Gets the installed app version + Future currentVersion() async { + // final info = + // await PackageInfo.fromPlatform(); + + return "test"; + } + + + /// Checks the update server + Future checkForUpdate() async { + final version = + await currentVersion(); + + + final url = Uri.parse( + "$serverUrl/api/update?version=$version", + ); + + + final response = + await http.get(url); + + + if (response.statusCode != 200) { + throw Exception( + "Update server unavailable", + ); + } + + + final data = + jsonDecode(response.body); + + + if (data["update"] != true) { + return null; + } + + + return UpdateInfo.fromJson(data); + } + + + /// Downloads the APK + Future downloadApk(UpdateInfo update, { + + Function(double progress)? onProgress, + + }) async { + final url = + update.download.startsWith("http") + ? update.download + : "$serverUrl${update.download}"; + + + final request = + http.Request( + "GET", + Uri.parse(url), + ); + + + final response = + await request.send(); + + + if (response.statusCode != 200) { + throw Exception( + "APK download failed", + ); + } + + + final total = + response.contentLength ?? 0; + + + int received = 0; + + + final directory = + await getTemporaryDirectory(); + + + final file = + File( + "${directory.path}/update.apk", + ); + + + final sink = + file.openWrite(); + + await for (final chunk in response.stream) { + sink.add(chunk); + + received += chunk.length; + + if (total > 0 && onProgress != null) { + onProgress( + received / total, + ); + } + } + + await sink.close(); + + return file; + } + + + /// Verifies APK checksum + Future verifySha256(File file, + String expectedHash,) async { + final bytes = + await file.readAsBytes(); + + final digest = + sha256.convert(bytes); + + return digest.toString() + .toLowerCase() + == + expectedHash.toLowerCase(); + } + + + /// Opens Android APK installer + Future installApk(File file,) async { + // final result = + // await OpenFilex.open( + // file.path, + // ); + // + // + // if (result.type != ResultType.done) { + // throw Exception( + // "Could not open APK installer", + // ); + // } + } + + + /// Full update flow + /// + /// Returns: + /// - null if no update exists + /// - UpdateInfo if update is available + /// + Future update({ + + Function(double progress)? onProgress, + + }) async { + final info = + await checkForUpdate(); + + if (info == null) { + return null; + } + + final apk = + await downloadApk( + info, + onProgress: onProgress, + ); + + final valid = + await verifySha256( + apk, + info.sha256, + ); + + if (!valid) { + throw Exception( + "APK checksum mismatch", + ); + } + + await installApk( + apk, + ); + + return info; + } + +} \ No newline at end of file diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index e8b67c3..667f1b1 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -8,9 +8,11 @@ import '../services/product_service.dart'; class BarScreenViewModel extends ChangeNotifier { final BarTabService barTabService; + final ProductService productService; BarScreenViewModel({ required this.barTabService, + required this.productService, }); List _tabs = []; @@ -74,9 +76,7 @@ class BarScreenViewModel extends ChangeNotifier { } Future createTab(String customerName) async { - final tab = await barTabService.createTab( - customerName: customerName, - ); + final tab = await barTabService.createTab(customerName: customerName); _selectedTabId = tab.id; await _reloadTabs(); @@ -87,23 +87,34 @@ class BarScreenViewModel extends ChangeNotifier { if (tab == null) return; + if (product.stockQuantity <= 0) { + throw Exception('Product is out of stock.'); + } + await barTabService.addProductToTab( tabId: tab.id, product: product, ); + await productService.decreaseStock(product.id, 1); + await _reloadTabs(); } - Future changeItemQuantity( - TabItem item, - int quantity, - ) async { + Future changeItemQuantity(TabItem item, int quantity) async { + final difference = quantity - item.quantity; + await barTabService.updateTabItemQuantity( tabItemId: item.id, quantity: quantity, ); + if (difference > 0) { + await productService.decreaseStock(item.productId, difference); + } else if (difference < 0) { + await productService.increaseStock(item.productId, -difference); + } + await _reloadTabs(); } @@ -126,10 +137,11 @@ class BarScreenViewModel extends ChangeNotifier { Future _reloadTabs() async { _tabs = await barTabService.getOpenTabs(); - if (_selectedTabId == null || !_tabs.any((tab) => tab.id == _selectedTabId)) { + if (_selectedTabId == null || + !_tabs.any((tab) => tab.id == _selectedTabId)) { _selectedTabId = _tabs.isEmpty ? null : _tabs.first.id; } notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart index 8658778..679a15d 100644 --- a/lib/viewmodels/product_list_view_model.dart +++ b/lib/viewmodels/product_list_view_model.dart @@ -56,6 +56,20 @@ class ProductListViewModel extends ChangeNotifier { _isLoading = false; notifyListeners(); } + + try { + _products = await productService.getProducts(); + + debugPrint( + _products.map((p) => '${p.name}: ${p.stockQuantity}').join('\n'), + ); + } catch (_) { + _errorMessage = 'Could not load products.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } } Future addProduct({ diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart new file mode 100644 index 0000000..dd04bb0 --- /dev/null +++ b/lib/viewmodels/settings_view_model.dart @@ -0,0 +1,66 @@ +import 'package:flutter/foundation.dart'; + +import '../models/settings.dart'; +import '../services/settings_service.dart'; + +class SettingsViewModel extends ChangeNotifier { + final SettingsService settingsService; + + SettingsViewModel({required this.settingsService}); + + AppSettings _settings = AppSettings.defaults; + bool _isLoading = false; + bool _hasLoaded = false; + String? _errorMessage; + + AppSettings get settings => _settings; + bool get isLoading => _isLoading; + bool get hasLoaded => _hasLoaded; + String? get errorMessage => _errorMessage; + + Future ensureLoaded() async { + if (_hasLoaded || _isLoading) return; + await load(); + } + + Future load() async { + if (_isLoading) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _settings = await settingsService.getSettings(); + } catch (_) { + _errorMessage = 'Could not load settings.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } + } + + /// Just flips the preference flag. Caller is responsible for having + /// already set/verified the actual PIN via PinLockViewModel first. + Future updatePinRequired(bool value) => + _save(_settings.copyWith(pinRequired: value)); + + Future updateThemeMode(AppThemeMode mode) => + _save(_settings.copyWith(themeMode: mode)); + + Future _save(AppSettings updated) async { + final previous = _settings; + _settings = updated; + _errorMessage = null; + notifyListeners(); + + try { + await settingsService.saveSettings(updated); + } catch (_) { + _settings = previous; + _errorMessage = 'Could not save settings.'; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 056168d..5a3c9e2 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widget_previews.dart'; import 'package:go_router/go_router.dart'; import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; import 'package:kooltab2/viewmodels/product_list_view_model.dart'; @@ -43,6 +41,12 @@ class BarScreenView extends StatelessWidget { icon: const Icon(Icons.refresh_rounded), ), const SizedBox(width: 6), + IconButton( + tooltip: 'Settings', + onPressed: () => context.go('/settings'), + icon: const Icon(Icons.settings), + ), + const SizedBox(width: 6), IconButton( tooltip: 'logout', onPressed: (){ @@ -234,12 +238,12 @@ class _ProductGrid extends StatelessWidget { padding: const EdgeInsets.all(20), decoration: BoxDecoration( shape: BoxShape.circle, - color: scheme.onSurface.withOpacity(0.05), + color: scheme.onSurface.withValues(alpha: 0.05), ), child: Icon( Icons.inventory_2_outlined, size: 40, - color: scheme.onSurface.withOpacity(0.3), + color: scheme.onSurface.withValues(alpha: 0.3), ), ), const SizedBox(height: 16), @@ -280,7 +284,7 @@ class _ProductGrid extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: scheme.onSurface.withOpacity(0.06), + color: scheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(999), ), child: Row( @@ -289,7 +293,7 @@ class _ProductGrid extends StatelessWidget { Icon( Icons.info_outline_rounded, size: 14, - color: scheme.onSurface.withOpacity(0.5), + color: scheme.onSurface.withValues(alpha: 0.5), ), const SizedBox(width: 4), Flexible( @@ -369,7 +373,7 @@ class _ProductTile extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - border: Border.all(color: scheme.onSurface.withOpacity(0.08), width: 1), + border: Border.all(color: scheme.onSurface.withValues(alpha: 0.08), width: 1), ), child: ClipRRect( borderRadius: BorderRadius.circular(15), @@ -396,7 +400,7 @@ class _ProductTile extends StatelessWidget { Icon( Icons.image_not_supported_outlined, size: 34, - color: scheme.onSurface.withOpacity(0.3), + color: scheme.onSurface.withValues(alpha: 0.3), ), const SizedBox(height: 6), Text( @@ -406,9 +410,6 @@ class _ProductTile extends StatelessWidget { ], ), ), - // Scrim stays black regardless of theme — it's for legibility - // of the (usually light/photographic) image beneath it, not - // themed UI chrome. if (_hasImage) Positioned.fill( child: DecoratedBox( @@ -418,7 +419,7 @@ class _ProductTile extends StatelessWidget { end: Alignment.bottomCenter, colors: [ Colors.transparent, - Colors.black.withOpacity(0.35), + Colors.black.withValues(alpha: 0.35), ], stops: const [0.6, 1.0], ), @@ -541,7 +542,7 @@ class _TabPanelState extends State<_TabPanel> { vertical: 2, ), decoration: BoxDecoration( - color: scheme.onSurface.withOpacity(0.06), + color: scheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(999), ), child: Text( @@ -577,7 +578,7 @@ class _TabPanelState extends State<_TabPanel> { Icon( Icons.receipt_long_outlined, size: 36, - color: scheme.onSurface.withOpacity(0.25), + color: scheme.onSurface.withValues(alpha: 0.25), ), const SizedBox(height: 10), Text( @@ -627,7 +628,7 @@ class _OpenTabsList extends StatelessWidget { return ListView.separated( itemCount: tabs.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final tab = tabs[index]; final selected = tab.id == selectedTabId; @@ -665,13 +666,13 @@ class _OpenTabsList extends StatelessWidget { duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: selected - ? primary.withOpacity(0.14) - : scheme.onSurface.withOpacity(0.04), + ? primary.withValues(alpha: 0.14) + : scheme.onSurface.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), border: Border.all( color: selected - ? primary.withOpacity(0.6) - : scheme.onSurface.withOpacity(0.06), + ? primary.withValues(alpha: 0.6) + : scheme.onSurface.withValues(alpha: 0.06), width: selected ? 1.4 : 1, ), ), @@ -764,7 +765,7 @@ class _SelectedTabDetails extends StatelessWidget { Icon( Icons.local_bar_outlined, size: 32, - color: scheme.onSurface.withOpacity(0.25), + color: scheme.onSurface.withValues(alpha: 0.25), ), const SizedBox(height: 8), Text( @@ -776,7 +777,7 @@ class _SelectedTabDetails extends StatelessWidget { ) : ListView.separated( itemCount: tab.items.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final item = tab.items[index]; @@ -791,10 +792,10 @@ class _SelectedTabDetails extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.12), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), ), ), child: Row( @@ -858,7 +859,7 @@ class _TabItemRow extends StatelessWidget { ), Container( decoration: BoxDecoration( - color: scheme.onSurface.withOpacity(0.05), + color: scheme.onSurface.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(999), ), child: Row( diff --git a/lib/views/history_screen_view.dart b/lib/views/history_screen_view.dart index 00fc8ff..dbe3f89 100644 --- a/lib/views/history_screen_view.dart +++ b/lib/views/history_screen_view.dart @@ -118,7 +118,7 @@ class _HistoryScreenViewState extends State with RouteAware { : ListView.separated( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), itemCount: viewModel.closedTabs.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), + separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) { final closedTab = viewModel.closedTabs[index]; @@ -147,12 +147,12 @@ class _EmptyState extends StatelessWidget { padding: const EdgeInsets.all(20), decoration: BoxDecoration( shape: BoxShape.circle, - color: scheme.onSurface.withOpacity(0.05), + color: scheme.onSurface.withValues(alpha: 0.05), ), child: Icon( Icons.history_rounded, size: 40, - color: scheme.onSurface.withOpacity(0.3), + color: scheme.onSurface.withValues(alpha: 0.3), ), ), const SizedBox(height: 16), @@ -191,9 +191,9 @@ class _ClosedTabCardState extends State<_ClosedTabCard> { return Container( decoration: BoxDecoration( - color: scheme.onSurface.withOpacity(0.04), + color: scheme.onSurface.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), - border: Border.all(color: scheme.onSurface.withOpacity(0.06)), + border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)), ), clipBehavior: Clip.antiAlias, child: Material( @@ -249,7 +249,7 @@ class _ClosedTabCardState extends State<_ClosedTabCard> { _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, - color: scheme.onSurface.withOpacity(0.5), + color: scheme.onSurface.withValues(alpha: 0.5), ), ], ), diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index 61047c5..30a9afb 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -210,7 +210,7 @@ class _PinDots extends StatelessWidget { shape: BoxShape.circle, color: isFilled ? scheme.primary : Colors.transparent, border: Border.all( - color: isFilled ? scheme.primary : scheme.onSurface.withOpacity(0.3), + color: isFilled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.3), width: 1.4, ), ), @@ -305,21 +305,21 @@ class _KeypadButton extends StatelessWidget { width: 72, height: 72, child: Material( - color: scheme.onSurface.withOpacity(0.04), + color: scheme.onSurface.withValues(alpha: 0.04), shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: onTap, child: Center( child: icon != null - ? Icon(icon, color: scheme.onSurface.withOpacity(0.8)) + ? Icon(icon, color: scheme.onSurface.withValues(alpha: 0.8)) : Text( label!, style: TextStyle( fontSize: 24, fontWeight: FontWeight.w600, color: onTap == null - ? scheme.onSurface.withOpacity(0.3) + ? scheme.onSurface.withValues(alpha: 0.3) : scheme.onSurface, ), ), diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 8cc06db..c177318 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -6,9 +6,24 @@ import 'package:provider/provider.dart'; import '../viewmodels/product_list_view_model.dart'; -class ProductListView extends StatelessWidget { +class ProductListView extends StatefulWidget { const ProductListView({super.key}); + @override + State createState() => _ProductListViewState(); +} + +class _ProductListViewState extends State { + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadProducts(); + }); + } + @override Widget build(BuildContext context) { final viewModel = context.watch(); @@ -53,7 +68,7 @@ class ProductListView extends StatelessWidget { return ListView.separated( padding: const EdgeInsets.all(16), itemCount: viewModel.products.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final product = viewModel.products[index]; diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart new file mode 100644 index 0000000..ab190ab --- /dev/null +++ b/lib/views/settings_view.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../models/settings.dart'; +import '../viewmodels/pin_lock_view_model.dart'; +import '../viewmodels/settings_view_model.dart'; + +class SettingsScreenView extends StatefulWidget { + const SettingsScreenView({super.key}); + + @override + State createState() => _SettingsScreenViewState(); +} + +class _SettingsScreenViewState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().ensureLoaded(); + context.read().ensureLoaded(); + }); + } + + Future _promptPin(String title, {String hint = 'Enter PIN'}) { + final controller = TextEditingController(); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + obscureText: true, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration(hintText: hint), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Confirm'), + ), + ], + ), + ); + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + Future _enablePin() async { + final pin = await _promptPin('Set PIN Code', hint: '4–6 digits'); + if (pin == null) return; + + if (pin.length < 4) { + _showError('PIN must be at least 4 digits'); + return; + } + + final pinLockViewModel = context.read(); + final success = await pinLockViewModel.setPin(pin); + + if (!success) { + _showError(pinLockViewModel.errorMessage ?? 'Could not set PIN.'); + return; + } + + await context.read().updatePinRequired(true); + } + + Future _changePin() async { + final current = await _promptPin('Enter Current PIN'); + if (current == null) return; + + final newPin = await _promptPin('Enter New PIN', hint: '4–6 digits'); + if (newPin == null) return; + + if (newPin.length < 4) { + _showError('PIN must be at least 4 digits'); + return; + } + + final pinLockViewModel = context.read(); + final success = await pinLockViewModel.changePin( + currentPin: current, + newPin: newPin, + ); + + if (!success) { + _showError(pinLockViewModel.errorMessage ?? 'Could not change PIN.'); + } + } + + Future _disablePin() async { + final current = await _promptPin('Enter Current PIN to Disable'); + if (current == null) return; + + final pinLockViewModel = context.read(); + final success = await pinLockViewModel.disablePin(current); + + if (!success) { + _showError(pinLockViewModel.errorMessage ?? 'Could not disable PIN.'); + return; + } + + await context.read().updatePinRequired(false); + } + + @override + Widget build(BuildContext context) { + final settingsViewModel = context.watch(); + final pinLockViewModel = context.watch(); + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + IconButton( + onPressed: () => context.go('/bar'), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 5), + const Text('Settings'), + ], + ), + ), + body: Builder( + builder: (context) { + final isLoading = settingsViewModel.isLoading || pinLockViewModel.isLoading; + final hasLoaded = settingsViewModel.hasLoaded && pinLockViewModel.hasLoaded; + + if (isLoading && !hasLoaded) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2.5), + ); + } + + final settings = settingsViewModel.settings; + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _SettingsSection( + title: 'Security', + children: [ + _SettingsSwitchTile( + icon: Icons.lock_outline_rounded, + title: 'PIN Required', + value: settings.pinRequired, + onChanged: (value) { + if (value) { + _enablePin(); + } else { + _disablePin(); + } + }, + ), + if (settings.pinRequired) + _SettingsTile( + icon: Icons.pin_rounded, + title: 'Change PIN', + onTap: _changePin, + ), + ], + ), + _SettingsSection( + title: 'Appearance', + children: [ + _SettingsTile( + icon: Icons.brightness_6_rounded, + title: 'Theme', + subtitle: switch (settings.themeMode) { + AppThemeMode.system => 'System', + AppThemeMode.light => 'Light', + AppThemeMode.dark => 'Dark', + }, + onTap: () async { + final selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: AppThemeMode.values.map((mode) { + return RadioListTile( + value: mode, + groupValue: settings.themeMode, + title: Text(switch (mode) { + AppThemeMode.system => 'System', + AppThemeMode.light => 'Light', + AppThemeMode.dark => 'Dark', + }), + onChanged: (value) => Navigator.pop(context, value), + ); + }).toList(), + ), + ), + ); + + if (selected != null) { + await settingsViewModel.updateThemeMode(selected); + } + }, + ), + ], + ), + ], + ); + }, + ), + ); + } +} + +class _SettingsSection extends StatelessWidget { + final String title; + final List children; + + const _SettingsSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: scheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: scheme.onSurface.withValues(alpha: 0.06)), + ), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ), + ], + ), + ); + } +} + +class _SettingsTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + const _SettingsTile({ + required this.icon, + required this.title, + this.subtitle, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 22, color: scheme.onSurface.withValues(alpha: 0.7)), + const SizedBox(width: 14), + Expanded( + child: Text( + title, + style: TextStyle(fontWeight: FontWeight.w600, color: scheme.onSurface), + ), + ), + if (subtitle != null) ...[ + Text(subtitle!, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: 4), + ], + if (onTap != null) + Icon(Icons.chevron_right_rounded, color: scheme.onSurface.withValues(alpha: 0.3)), + ], + ), + ), + ), + ); + } +} + +class _SettingsSwitchTile extends StatelessWidget { + final IconData icon; + final String title; + final bool value; + final ValueChanged onChanged; + + const _SettingsSwitchTile({ + required this.icon, + required this.title, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Icon(icon, size: 22, color: scheme.onSurface.withValues(alpha: 0.7)), + const SizedBox(width: 14), + Expanded( + child: Text( + title, + style: TextStyle(fontWeight: FontWeight.w600, color: scheme.onSurface), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 7f38a8c..925a2f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,10 @@ dependencies: intl: ^0.20.3 flutter_secure_storage: ^9.0.0 crypto: ^3.0.0 +# http: ^1.6.0 +# package_info_plus: ^10.2.1 +# open_filex: ^4.7.0 +# permission_handler: ^12.0.3 dev_dependencies: diff --git a/test/widget_test.dart b/test/widget_test.dart index 1e95b30..a4fae31 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,10 +5,7 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltab2/main.dart'; void main() { }