Initial and done prolly

This commit is contained in:
Andrew 2025-01-05 16:01:21 +07:00
commit 6f88b9966f
175 changed files with 15445 additions and 0 deletions

165
lib/common/icons.dart Normal file
View file

@ -0,0 +1,165 @@
import 'package:flutter/widgets.dart';
import 'package:get/utils.dart';
import 'package:icons_plus/icons_plus.dart';
enum ProductCategoryIcons {
birthday_2,
bone,
bowl,
bread,
cake,
candy_2,
candy,
carrot,
champagne,
chicken,
chopsticks,
cookie,
cookie_man,
cupcake,
drink,
egg_crack,
egg,
fish,
fork_knife,
fork,
fork_spoon,
glass_cup,
ice_cream_2,
ice_cream,
lollipop,
spoon,
sugar_coated_haws,
teacup,
wine,
wineglass_2,
wineglass;
IconData get icon {
switch (this) {
case ProductCategoryIcons.birthday_2:
return MingCute.birthday_2_line;
case ProductCategoryIcons.bone:
return MingCute.bone_line;
case ProductCategoryIcons.bowl:
return MingCute.bowl_line;
case ProductCategoryIcons.bread:
return MingCute.bread_line;
case ProductCategoryIcons.cake:
return MingCute.cake_line;
case ProductCategoryIcons.candy_2:
return MingCute.candy_2_line;
case ProductCategoryIcons.candy:
return MingCute.candy_line;
case ProductCategoryIcons.carrot:
return MingCute.carrot_line;
case ProductCategoryIcons.champagne:
return MingCute.champagne_line;
case ProductCategoryIcons.chicken:
return MingCute.chicken_line;
case ProductCategoryIcons.chopsticks:
return MingCute.chopsticks_line;
case ProductCategoryIcons.cookie:
return MingCute.cookie_line;
case ProductCategoryIcons.cookie_man:
return MingCute.cookie_man_line;
case ProductCategoryIcons.cupcake:
return MingCute.cupcake_line;
case ProductCategoryIcons.drink:
return MingCute.drink_line;
case ProductCategoryIcons.egg_crack:
return MingCute.egg_crack_line;
case ProductCategoryIcons.egg:
return MingCute.egg_line;
case ProductCategoryIcons.fish:
return MingCute.fish_line;
case ProductCategoryIcons.fork_knife:
return MingCute.fork_knife_line;
case ProductCategoryIcons.fork:
return MingCute.fork_line;
case ProductCategoryIcons.fork_spoon:
return MingCute.fork_spoon_line;
case ProductCategoryIcons.glass_cup:
return MingCute.glass_cup_line;
case ProductCategoryIcons.ice_cream_2:
return MingCute.ice_cream_2_line;
case ProductCategoryIcons.ice_cream:
return MingCute.ice_cream_line;
case ProductCategoryIcons.lollipop:
return MingCute.lollipop_line;
case ProductCategoryIcons.spoon:
return MingCute.spoon_line;
case ProductCategoryIcons.sugar_coated_haws:
return MingCute.sugar_coated_haws_line;
case ProductCategoryIcons.teacup:
return MingCute.teacup_line;
case ProductCategoryIcons.wine:
return MingCute.wine_line;
case ProductCategoryIcons.wineglass_2:
return MingCute.wineglass_2_line;
case ProductCategoryIcons.wineglass:
return MingCute.wineglass_line;
}
}
String get betterName {
final parts = name.split("_");
return parts.indexed.map((e) {
if (e.$1 == 0) {
return e.$2.capitalize;
}
return e.$2;
}).join(" ");
}
static ProductCategoryIcons fromName(String name) {
for (final v in values) {
if (v.name == name) {
return v;
}
}
return ProductCategoryIcons.fork;
}
}
enum StorageLocationIcon {
box_2,
box_3,
inbox,
package,
package_2;
IconData get icon {
switch (this) {
case StorageLocationIcon.box_2:
return MingCute.box_2_line;
case StorageLocationIcon.box_3:
return MingCute.box_3_line;
case StorageLocationIcon.inbox:
return MingCute.inbox_line;
case StorageLocationIcon.package:
return MingCute.package_line;
case StorageLocationIcon.package_2:
return MingCute.package_2_line;
}
}
static StorageLocationIcon fromName(String name) {
for (final v in values) {
if (v.name == name) {
return v;
}
}
return StorageLocationIcon.box_2;
}
String get betterName {
final parts = name.split("_");
return parts.indexed.map((e) {
if (e.$1 == 0) {
return e.$2.capitalize;
}
return e.$2;
}).join(" ");
}
}

View file

@ -0,0 +1,52 @@
part of '../database.dart';
extension ProductCategoryCrud on AppDatabase {
Stream<List<ProductCategoryData>> get productsCategoriesSubscription =>
managers.productCategory.watch();
Future<void> addProductCategory({
required String name,
required String icon,
}) async {
await managers.productCategory.create((c) => c(
name: name,
icon: icon,
));
}
Future<void> updateProductCategory({
required int id,
String? name,
String? icon,
}) async {
await managers.productCategory.filter((f) => f.id(id)).update((u) => u(
id: Value(id),
name: Value.absentIfNull(name),
icon: Value.absentIfNull(icon),
));
}
Future<void> deleteProductCategory(ProductCategoryData item) async {
await managers.productCategory.filter((f) => f.id(item.id)).delete();
}
Future<List<ProductCategoryData>> getProductCategories() async {
final categories = await managers.productCategory.get();
return categories;
}
Future<List<(ProductCategoryData, int)>>
getProductCategoriesWithCounts() async {
final categories = await managers.productCategory.get();
return [
for (final category in categories)
(
category,
await managers.product
.filter((f) => f.category.id(category.id))
.count()
),
];
}
}

View file

@ -0,0 +1,137 @@
part of '../database.dart';
extension ProductCrud on AppDatabase {
Stream<List<(ProductData, ProductCategoryData, StorageLocationData)>>
get productsSubscription => managers.product
.orderBy((o) => o.expiryDate.asc())
.withReferences((prefetch) => prefetch(
category: true,
storage: true,
))
.watch()
.asyncMap(
(products) async => [
for (final (item, refs) in products)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
],
);
Stream<List<(ProductData, ProductCategoryData, StorageLocationData)>>
get soonExpirySubscription => managers.product
.filter((f) =>
f.expiryDate.isAfter(DateTime.now().add(const Duration(days: 3))))
.orderBy((o) => o.expiryDate.asc())
.withReferences((prefetch) => prefetch(
category: true,
storage: true,
))
.watch()
.asyncMap(
(products) async => [
for (final (item, refs) in products)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
],
);
Future<void> addProduct({
required String name,
required ProductCategoryData category,
required StorageLocationData storage,
required double quantity,
required String unit,
DateTime? purchaseDate,
required DateTime expiryDate,
required String barcode,
}) async {
await managers.product.create((c) => c(
name: name,
category: category.id,
storage: storage.id,
quantity: quantity,
unit: unit,
barcode: barcode,
purchaseDate: Value(purchaseDate ?? DateTime.now()),
expiryDate: Value(expiryDate),
));
}
Future<void> updateProduct({
required int id,
String? name,
ProductCategoryData? category,
StorageLocationData? storage,
double? quantity,
String? unit,
DateTime? purchaseDate,
DateTime? expiryDate,
String? barcode,
}) async {
await managers.product.filter((f) => f.id(id)).update((u) => u(
name: Value.absentIfNull(name),
category: Value.absentIfNull(category?.id),
storage: Value.absentIfNull(storage?.id),
quantity: Value.absentIfNull(quantity),
unit: Value.absentIfNull(unit),
barcode: Value.absentIfNull(barcode),
purchaseDate: Value.absentIfNull(purchaseDate),
expiryDate: Value.absentIfNull(expiryDate),
));
}
Future<void> deleteProduct(ProductData item) async {
await managers.product.filter((f) => f.id(item.id)).delete();
}
Future<List<(ProductData, ProductCategoryData, StorageLocationData)>>
getProducts() async {
final products = await managers.product
.orderBy((o) => o.purchaseDate.asc())
.withReferences((prefetch) => prefetch(
category: true,
storage: true,
))
.get();
return [
for (final (item, refs) in products)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
];
}
Future<List<(ProductData, ProductCategoryData, StorageLocationData)>>
getSoonExpiryProducts() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final products = await managers.product
.filter(
(f) => f.expiryDate.isBefore(today.add(const Duration(days: 3))),
)
.orderBy((o) => o.expiryDate.desc())
.withReferences((prefetch) => prefetch(
category: true,
storage: true,
))
.get();
return [
for (final (item, refs) in products)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
];
}
}

View file

@ -0,0 +1,84 @@
part of '../database.dart';
extension ShoppingListItemCrud on AppDatabase {
Stream<List<(ShoppingListItemData, ProductCategoryData, StorageLocationData)>>
get shoppingListSubscription => managers.shoppingListItem
.withReferences(
(prefetch) => prefetch(
category: true,
storage: true,
),
)
.watch()
.asyncMap(
(shoppingList) async => [
for (final (item, refs) in shoppingList)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
],
);
Future<void> addShoppingListItem({
required double quantity,
required String name,
required ProductCategoryData category,
required StorageLocationData storage,
required String unit,
}) async {
await managers.shoppingListItem.create((c) => c(
quantity: quantity,
name: name,
category: category.id,
storage: storage.id,
unit: unit,
));
}
Future<void> updateShoppingListItem({
required int id,
double? quantity,
String? name,
ProductCategoryData? category,
StorageLocationData? storage,
String? unit,
bool? isPurchased,
}) async {
await managers.shoppingListItem.filter((f) => f.id(id)).update((u) => u(
id: Value(id),
quantity: Value.absentIfNull(quantity),
isPurchased: Value.absentIfNull(isPurchased),
name: Value.absentIfNull(name),
category: Value.absentIfNull(category?.id),
storage: Value.absentIfNull(storage?.id),
unit: Value.absentIfNull(unit),
));
}
Future<void> deleteShoppingListItem(ShoppingListItemData item) async {
await managers.shoppingListItem.filter((f) => f.id(item.id)).delete();
}
Future<List<(ShoppingListItemData, ProductCategoryData, StorageLocationData)>>
getShoppingList() async {
final shoppingList = await managers.shoppingListItem
.withReferences(
(prefetch) => prefetch(
category: true,
storage: true,
),
)
.get();
return [
for (final (item, refs) in shoppingList)
(
item,
await refs.category.getSingle(),
await refs.storage.getSingle(),
),
];
}
}

View file

@ -0,0 +1,88 @@
part of '../database.dart';
extension StorageLocationsCrud on AppDatabase {
Stream<List<StorageLocationData>> get storageLocationsSubscription =>
managers.storageLocation.watch();
Future<int> addStorageLocation({
required String name,
required String description,
required TemperatureMode temperatureMode,
required String icon,
}) async {
return await managers.storageLocation.create((c) => c(
name: name,
description: description,
temperatureMode: temperatureMode.name,
icon: icon,
));
}
Future<int> updateStorageLocation({
required int id,
String? name,
String? description,
TemperatureMode? temperatureMode,
double? capacity,
String? icon,
}) async {
return await managers.storageLocation
.filter((f) => f.id(id))
.update((u) => u(
name: Value.absentIfNull(name),
description: Value.absentIfNull(description),
temperatureMode: Value.absentIfNull(temperatureMode?.name),
icon: Value.absentIfNull(icon),
));
}
Future<void> deleteStorageLocation(StorageLocationData item) async {
await managers.storageLocation.filter((f) => f.id(item.id)).delete();
}
Future<List<(StorageLocationData, List<(ProductData, ProductCategoryData)>)>>
getStorageLocations() async {
final storageLocations = await managers.storageLocation
.withReferences((prefetch) => prefetch(productRefs: true))
.get();
final result =
<(StorageLocationData, List<(ProductData, ProductCategoryData)>)>[];
for (final (storage, refs) in storageLocations) {
final products = await refs.productRefs
.withReferences((prefetch) => prefetch(category: true))
.get();
final productsWithCategories = <(ProductData, ProductCategoryData)>[];
for (final (product, refs) in products) {
productsWithCategories.add((
product,
await refs.category.getSingle(),
));
}
result.add((
storage,
productsWithCategories,
));
}
return result;
}
Future<bool> defaultExists() async {
final exists = await managers.storageLocation
.filter((f) => f.isDefault(true))
.exists();
return exists;
}
Future<void> switchDefault(int newDefaultId) async {
await managers.storageLocation
.filter((f) => f.isDefault(true))
.update((u) => u(isDefault: const Value(false)));
await managers.storageLocation
.filter((f) => f.id(newDefaultId))
.update((u) => u(isDefault: const Value(true)));
}
}

View file

@ -0,0 +1,84 @@
part of '../database.dart';
extension UserCrud on AppDatabase {
String hashPassword(String password) {
final passwordBytes = utf8.encode(password);
final hashedPassword = sha512224.convert(passwordBytes);
return hashedPassword.toString();
}
Future<UserData?> addUser({
required String login,
required String password,
}) async {
return await managers.user.createReturningOrNull((c) => c(
login: login,
password: hashPassword(password),
));
}
Future<List<UserData>> getUsers() async {
return await managers.user.get();
}
Future<bool> anyUserExists() async {
return await managers.user.exists();
}
Future<UserData?> findUser({
required String login,
required String password,
}) async {
return await managers.user
.filter((f) => f.login(login))
.filter((f) => f.password(hashPassword(password)))
.getSingleOrNull();
}
Future<UserData> getUser(UserData user) async {
return await managers.user.filter((f) => f.id(user.id)).getSingle();
}
Future<bool> updateUser(
UserData currentUser,
UserData updatedUser, {
String? login,
String? password,
}) async {
if (currentUser.id == updatedUser.id) {
return false;
}
await managers.user.filter((f) => f.id(updatedUser.id)).update((u) => u(
login: Value.absentIfNull(login),
password: Value.absentIfNull(
password != null ? hashPassword(password) : null),
));
return true;
}
Future<bool> updateUserPassword(
UserData user, {
required String password,
}) async {
await managers.user.filter((f) => f.id(user.id)).update((u) => u(
password: Value(hashPassword(password)),
));
return true;
}
Future<bool> deleteUser(UserData user) async {
final usersCount = await managers.user.count();
if (usersCount == 0) {
return false;
}
final deletedCount = await managers.user
.filter(
(f) => f.id(user.id),
)
.delete();
return deletedCount != 0;
}
}

50
lib/db/database.dart Normal file
View file

@ -0,0 +1,50 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import '../models/enums/temperature_mode.dart';
import 'database.steps.dart';
import 'tables/product.dart';
import 'tables/product_category.dart';
import 'tables/shopping_list_item.dart';
import 'tables/storage_location.dart';
import 'tables/users_table.dart';
part 'database.g.dart';
part 'crud/product_crud.dart';
part 'crud/product_category_crud.dart';
part 'crud/shopping_list_crud.dart';
part 'crud/storage_locations_crud.dart';
part 'crud/user_crud.dart';
@DriftDatabase(
tables: [ProductCategory, Product, ShoppingListItem, StorageLocation, User])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 2;
static QueryExecutor _openConnection() {
return driftDatabase(name: "groceries_manager_db");
}
@override
MigrationStrategy get migration {
return MigrationStrategy(
onUpgrade: stepByStep(
from1To2: (m, schema) async {
m.createTable(schema.user);
},
),
);
}
}
extension BetterConversionStorageLocation on StorageLocationData {
TemperatureMode get temperatureModeE =>
TemperatureMode.fromName(temperatureMode);
}

3265
lib/db/database.g.dart Normal file

File diff suppressed because it is too large Load diff

273
lib/db/database.steps.dart Normal file
View file

@ -0,0 +1,273 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema2 extends i0.VersionedSchema {
Schema2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
productCategory,
storageLocation,
product,
shoppingListItem,
user,
];
late final Shape0 productCategory = Shape0(
source: i0.VersionedTable(
entityName: 'product_category',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 storageLocation = Shape1(
source: i0.VersionedTable(
entityName: 'storage_location',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_3,
_column_4,
_column_2,
_column_5,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 product = Shape2(
source: i0.VersionedTable(
entityName: 'product',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 shoppingListItem = Shape3(
source: i0.VersionedTable(
entityName: 'shopping_list_item',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_6,
_column_7,
_column_8,
_column_9,
_column_13,
_column_14,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 user = Shape4(
source: i0.VersionedTable(
entityName: 'user',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_15,
_column_16,
],
attachedDatabase: database,
),
alias: null);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get icon =>
columnsByName['icon']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
i1.GeneratedColumn<int>('id', aliasedName, false,
hasAutoIncrement: true,
type: i1.DriftSqlType.int,
defaultConstraints:
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
i1.GeneratedColumn<String>('icon', aliasedName, false,
type: i1.DriftSqlType.string);
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get temperatureMode =>
columnsByName['temperature_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get icon =>
columnsByName['icon']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isDefault =>
columnsByName['is_default']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_3(String aliasedName) =>
i1.GeneratedColumn<String>('description', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
i1.GeneratedColumn<String>('temperature_mode', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_5(String aliasedName) =>
i1.GeneratedColumn<bool>('is_default', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_default" IN (0, 1))'),
defaultValue: const Constant(false));
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get category =>
columnsByName['category']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get storage =>
columnsByName['storage']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<double> get quantity =>
columnsByName['quantity']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<String> get unit =>
columnsByName['unit']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get purchaseDate =>
columnsByName['purchase_date']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get expiryDate =>
columnsByName['expiry_date']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get barcode =>
columnsByName['barcode']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
i1.GeneratedColumn<int>('category', aliasedName, false,
type: i1.DriftSqlType.int,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES product_category (id)'));
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
i1.GeneratedColumn<int>('storage', aliasedName, false,
type: i1.DriftSqlType.int,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES storage_location (id)'));
i1.GeneratedColumn<double> _column_8(String aliasedName) =>
i1.GeneratedColumn<double>('quantity', aliasedName, false,
type: i1.DriftSqlType.double);
i1.GeneratedColumn<String> _column_9(String aliasedName) =>
i1.GeneratedColumn<String>('unit', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_10(String aliasedName) =>
i1.GeneratedColumn<DateTime>('purchase_date', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
i1.GeneratedColumn<DateTime>('expiry_date', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>('barcode', aliasedName, false,
additionalChecks: i1.GeneratedColumn.checkTextLength(maxTextLength: 20),
type: i1.DriftSqlType.string);
class Shape3 extends i0.VersionedTable {
Shape3({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get category =>
columnsByName['category']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get storage =>
columnsByName['storage']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<double> get quantity =>
columnsByName['quantity']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<String> get unit =>
columnsByName['unit']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isPurchased =>
columnsByName['is_purchased']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get dateAdded =>
columnsByName['date_added']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<bool> _column_13(String aliasedName) =>
i1.GeneratedColumn<bool>('is_purchased', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_purchased" IN (0, 1))'),
defaultValue: const Constant(false));
i1.GeneratedColumn<DateTime> _column_14(String aliasedName) =>
i1.GeneratedColumn<DateTime>('date_added', aliasedName, true,
type: i1.DriftSqlType.dateTime, defaultValue: currentDateAndTime);
class Shape4 extends i0.VersionedTable {
Shape4({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get login =>
columnsByName['login']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get password =>
columnsByName['password']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
i1.GeneratedColumn<String>('login', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
i1.GeneratedColumn<String>('password', aliasedName, false,
type: i1.DriftSqlType.string);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = Schema2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
));

View file

@ -0,0 +1,15 @@
import 'package:drift/drift.dart';
import 'package:groceries_manager/db/tables/product_category.dart';
import 'package:groceries_manager/db/tables/storage_location.dart';
class Product extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
IntColumn get category => integer().references(ProductCategory, #id)();
IntColumn get storage => integer().references(StorageLocation, #id)();
RealColumn get quantity => real()();
TextColumn get unit => text()();
DateTimeColumn get purchaseDate => dateTime().nullable()();
DateTimeColumn get expiryDate => dateTime().nullable()();
TextColumn get barcode => text().withLength(max: 20)();
}

View file

@ -0,0 +1,7 @@
import 'package:drift/drift.dart';
class ProductCategory extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get icon => text()();
}

View file

@ -0,0 +1,16 @@
import 'package:drift/drift.dart';
import 'product_category.dart';
import 'storage_location.dart';
class ShoppingListItem extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
IntColumn get category => integer().references(ProductCategory, #id)();
IntColumn get storage => integer().references(StorageLocation, #id)();
RealColumn get quantity => real()();
TextColumn get unit => text()();
BoolColumn get isPurchased => boolean().withDefault(const Constant(false))();
DateTimeColumn get dateAdded =>
dateTime().withDefault(currentDateAndTime).nullable()();
}

View file

@ -0,0 +1,10 @@
import 'package:drift/drift.dart';
class StorageLocation extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get description => text()();
TextColumn get temperatureMode => text()();
TextColumn get icon => text()();
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
}

View file

@ -0,0 +1,7 @@
import 'package:drift/drift.dart';
class User extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get login => text().unique()();
TextColumn get password => text()();
}

60
lib/main.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:toastification/toastification.dart';
import 'db/database.dart';
import 'pages/login/login_controller.dart';
import 'pages/login/login_page.dart';
import 'pages/main/home_controller.dart';
import 'pages/main/home_page.dart';
import 'pages/redirect/redirect_controller.dart';
import 'pages/redirect/redirect_page.dart';
import 'services/db_service.dart';
import 'services/toaster_service.dart';
void main() async {
Get.put(ToasterService());
await Get.put(DBService()).init();
WidgetsFlutterBinding.ensureInitialized();
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return ToastificationWrapper(
child: GetMaterialApp(
debugShowCheckedModeBanner: false,
initialRoute: "/",
getPages: [
GetPage(
name: "/",
page: () {
final user = Get.arguments;
if (user is! UserData) {
Get.put(RedirectController(redirectTo: "/login"));
return RedirectPage();
}
Get.put(HomeController(), permanent: true).setUser(user);
return const HomePage();
},
),
GetPage(
name: "/login",
page: () {
Get.put(LoginController());
return const LoginPage();
},
),
],
),
);
}
}

View file

@ -0,0 +1,23 @@
enum TemperatureMode {
refrigerated,
frozen,
dryRoom,
unspecified;
factory TemperatureMode.fromName(String name) {
for (final v in values) {
if (v.name == name) {
return v;
}
}
return unspecified;
}
String get betterName => switch (this) {
TemperatureMode.refrigerated => "Охлаждённое",
TemperatureMode.frozen => "Замороженное",
TemperatureMode.dryRoom => "Сухое",
TemperatureMode.unspecified => "Не указано",
};
}

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/db/database.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
enum LoginState {
idle,
loading,
}
class LoginController extends GetxController {
final loginController = TextEditingController();
final passwordController = TextEditingController();
final anyUserExists = false.obs;
final loginState = LoginState.idle.obs;
@override
void onInit() {
super.onInit();
init();
}
Future<void> init() async {
loginState.value = LoginState.loading;
anyUserExists.value = await DBService.to.db.anyUserExists();
loginState.value = LoginState.idle;
}
@override
void onClose() {
loginController.dispose();
passwordController.dispose();
super.onClose();
}
String sanitize(String value) {
return value.replaceAll(RegExp(r'[^а-яА-Яa-zA-Z0-9]'), '');
}
Future<void> register() async {
final login = loginController.text;
final password = passwordController.text;
if (login.isEmpty || password.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Логин и пароль не могут быть пустыми",
);
return;
}
loginState.value = LoginState.loading;
final UserData? user;
try {
user = await DBService.to.db.addUser(
login: login,
password: password,
);
} catch (e) {
ToasterService.to.error(
title: "Ошибка",
message: "Пользователь с таким логином уже существует",
);
return;
}
loginState.value = LoginState.idle;
if (user == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Пользователь с таким логином уже существует",
);
return;
}
ToasterService.to.success(
title: "Успех",
message: "Пользователь успешно зарегистрирован",
);
Get.offAllNamed("/", arguments: user);
}
Future<void> login() async {
final login = loginController.text;
final password = passwordController.text;
if (login.isEmpty || password.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Логин и пароль не могут быть пустыми!",
);
return;
}
loginState.value = LoginState.loading;
final user = await DBService.to.db.findUser(
login: login,
password: password,
);
loginState.value = LoginState.idle;
if (user == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Пользователь с такой связкой данных не существует!",
);
return;
}
ToasterService.to.success(
title: "Успех",
message: "Привет, ${user.login}!",
);
Get.offAllNamed("/", arguments: user);
}
}

View file

@ -0,0 +1,93 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'login_controller.dart';
class LoginPage extends GetView<LoginController> {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return Obx(
() => Scaffold(
body: switch (controller.loginState.value) {
LoginState.idle => Center(
child: SizedBox(
width: max(size.width * 0.3, 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Представьтесь!",
style: TextStyle(
fontSize: 20,
),
),
const SizedBox(height: 16),
TextFormField(
controller: controller.loginController,
decoration: const InputDecoration(
label: Text("Логин"),
),
onChanged: (value) {
controller.loginController.text =
controller.sanitize(value);
},
),
const SizedBox(height: 4),
TextFormField(
controller: controller.passwordController,
decoration: const InputDecoration(
label: Text("Пароль"),
),
obscureText: true,
onChanged: (value) {
controller.passwordController.text =
controller.sanitize(value);
},
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: () => controller.register(),
child: const Text("Зарегистрироваться"),
),
if (controller.anyUserExists.value)
ElevatedButton(
onPressed: () => controller.login(),
child: const Text("Войти"),
),
],
),
],
),
),
),
LoginState.loading => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"GM",
style: TextStyle(
fontSize: 20,
),
),
Text("Проверяем штуки..."),
SizedBox(height: 16),
CircularProgressIndicator(),
],
),
),
},
),
);
}
}

View file

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
import '../../../db/database.dart';
class CategoryEditorController extends GetxController {
final editedCategory = Rxn<ProductCategoryData>();
final categoryIcon = ProductCategoryIcons.fork.obs;
final nameController = TextEditingController();
void setEditedCategory(ProductCategoryData ecd) {
editedCategory.value = ecd;
nameController.text = ecd.name;
categoryIcon.value = ProductCategoryIcons.fromName(ecd.icon);
}
Future<void> save() async {
final text = nameController.text.trim();
if (text.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Имя категории не может быть пустым",
);
return;
}
if (editedCategory.value != null) {
await DBService.to.db.updateProductCategory(
id: editedCategory.value!.id,
icon: categoryIcon.value.name,
name: text,
);
ToasterService.to.success(
title: "Успех",
message: "Категория '$text' изменена",
);
} else {
await DBService.to.db.addProductCategory(
icon: categoryIcon.value.name,
name: text,
);
ToasterService.to.success(
title: "Успех",
message: "Категория '$text' создана",
);
}
cancel();
}
void cancel() {
Get.backLegacy();
}
}
class CategoryEditorDialog extends GetView<CategoryEditorController> {
const CategoryEditorDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: controller.editedCategory.value != null
? const Text("Редактируем категорию")
: const Text("Новая категория"),
content: SizedBox(
width: size.width * 0.5,
height: size.height * 0.9,
child: ListView(
children: [
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(label: Text("Название")),
),
Obx(
() => DropdownButtonFormField<ProductCategoryIcons>(
items: [
for (final item in ProductCategoryIcons.values)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
ProductCategoryIcons.fromName(item.name).icon,
),
const SizedBox(width: 8),
Text(item.betterName),
],
),
),
],
value: controller.categoryIcon.value,
isExpanded: true,
hint: const Text("Иконка"),
onChanged: (catIcon) {
if (catIcon != null) {
controller.categoryIcon.value = catIcon;
}
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Отмена"),
),
TextButton(
onPressed: () => controller.save(),
child: const Text("Сохранить"),
),
],
);
}
static Future<void> show({
ProductCategoryData? ecd,
}) async {
final controller = Get.put(CategoryEditorController());
if (ecd != null) {
controller.setEditedCategory(ecd);
}
await Get.dialog(
const CategoryEditorDialog(),
id: "CategoryEditorDialog",
name: "CategoryEditorDialog",
);
Get.delete<CategoryEditorController>();
}
}

View file

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
import 'package:groceries_manager/utils/get_interface_extension.dart';
import '../../../db/database.dart';
import 'category_editor_dialog.dart';
class CategoryManagerController extends GetxController {
final categories = <(ProductCategoryData, int)>[].obs;
@override
void onInit() {
super.onInit();
refreshData();
}
Future<void> refreshData() async {
final categories = await DBService.to.db.getProductCategoriesWithCounts();
this.categories.clear();
this.categories.addAll(categories);
}
void cancel() {
Get.backLegacy();
}
Future<void> deleteCategory(ProductCategoryData item) async {
final count = categories.firstWhere((e) => e.$1.id == item.id).$2;
if (count > 0) {
ToasterService.to.error(
title: "Ошибка",
message: "Нельзя удалить категорию, к которой привязаны продукты!",
);
return;
}
final confirmed = await Get.confirm(
title: "Внимание!",
content: "Удаление категории необратимо!\n"
"Точно хотите удалить '${item.name}'?",
);
if (!confirmed) return;
await DBService.to.db.deleteProductCategory(item);
refreshData();
}
Future<void> newCategory() async {
await CategoryEditorDialog.show();
refreshData();
}
Future<void> editCategory(ProductCategoryData item) async {
await CategoryEditorDialog.show(ecd: item);
refreshData();
}
}
class CategoryManagerDialog extends GetView<CategoryManagerController> {
const CategoryManagerDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: const Text("Управление категориями"),
content: SizedBox(
width: size.width * 0.9,
height: size.height * 0.9,
child: SingleChildScrollView(
child: Obx(
() => Table(
border: TableBorder.all(color: Colors.blueGrey.withAlpha(50)),
children: [
const TableRow(
children: [
TableCell(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Иконка"),
),
),
TableCell(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Название"),
),
),
TableCell(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Продукты в категории"),
),
),
TableCell(
child: SizedBox(),
),
],
),
for (final item in controller.categories)
TableRow(
key: ValueKey(item),
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
ProductCategoryIcons.fromName(item.$1.icon).icon,
),
),
),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(item.$1.name),
),
),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text("${item.$2}"),
),
),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
ElevatedButton.icon(
onPressed: () =>
controller.editCategory(item.$1),
icon: const Icon(Icons.edit_rounded),
label: const Text("Редактировать"),
),
const SizedBox(height: 4),
ElevatedButton.icon(
onPressed: () =>
controller.deleteCategory(item.$1),
icon: const Icon(Icons.delete_forever),
label: const Text("Удалить"),
),
],
),
),
),
],
),
],
),
),
),
),
actions: [
ElevatedButton.icon(
onPressed: () => controller.newCategory(),
icon: const Icon(Icons.add),
label: const Text("Новая категория"),
),
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Закрыть"),
),
],
);
}
static Future<void> show({
ProductCategoryData? ecd,
}) async {
Get.put(CategoryManagerController());
await Get.dialog(
const CategoryManagerDialog(),
id: "CategoryManagerDialog",
name: "CategoryManagerDialog",
);
Get.delete<CategoryManagerController>();
}
}

View file

@ -0,0 +1,325 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/utils/format_datetime_extension.dart';
import 'package:groceries_manager/pages/main/dialogs/category_editor_dialog.dart';
import '../../../db/database.dart';
import '../../../services/db_service.dart';
import '../../../services/toaster_service.dart';
import 'storage_location_editor_dialog.dart';
class ProductEditorController extends GetxController {
ProductData? editedProduct;
late final RxList<ProductCategoryData> categories;
late final RxList<StorageLocationData> storages;
final nameController = TextEditingController();
final category = Rxn<ProductCategoryData>();
final storage = Rxn<StorageLocationData>();
final quantityController = TextEditingController();
final unitController = TextEditingController();
final expiryDate = Rxn<DateTime>();
final barcodeController = TextEditingController();
void setEditedProduct(ProductData epd) {
editedProduct = epd;
nameController.text = epd.name;
category.value =
categories.firstWhere((e) => e.id == editedProduct!.category);
storage.value = storages.firstWhere((e) => e.id == editedProduct!.storage);
quantityController.text = epd.quantity.toString();
unitController.text = epd.unit;
expiryDate.value = epd.expiryDate;
barcodeController.text = epd.barcode;
}
Future<void> save() async {
final name = nameController.text.trim();
if (name.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Имя категории не может быть пустым",
);
return;
}
final category = this.category.value;
if (category == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Необходимо выбрать категорию продукта",
);
return;
}
final storage = this.storage.value;
if (storage == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Необходимо выбрать место хранения продукта",
);
return;
}
final quantity = quantityController.text;
if (!quantity.isNum) {
ToasterService.to.error(
title: "Ошибка",
message: "Кол-во продукта должно быть числом",
);
return;
}
final unit = unitController.text;
if (unit.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Единица измерения продукта должна быть указана",
);
return;
}
final expiryDate = this.expiryDate.value;
if (expiryDate == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Дата истечения срока годности должна быть указана",
);
return;
}
final barcode = barcodeController.text;
if (editedProduct != null) {
await DBService.to.db.updateProduct(
id: editedProduct!.id,
name: name,
category: category,
storage: storage,
quantity: double.parse(quantity),
unit: unit,
expiryDate: expiryDate,
barcode: barcode,
);
ToasterService.to.success(
title: "Успех",
message: "Продукт '$name' изменён",
);
} else {
await DBService.to.db.addProduct(
name: name,
category: category,
storage: storage,
quantity: double.parse(quantity),
unit: unit,
expiryDate: expiryDate,
barcode: barcode,
);
ToasterService.to.success(
title: "Успех",
message: "Продукт '$name' создан",
);
}
cancel();
}
void cancel() {
Get.backLegacy();
}
void setCategories(RxList<ProductCategoryData> categories) {
this.categories = categories;
}
void setStorages(RxList<StorageLocationData> storages) {
this.storages = storages;
}
void setStorage(StorageLocationData storage) {
this.storage.value = storage;
}
Future<void> newCategory() async {
await CategoryEditorDialog.show();
}
Future<void> newStorage() async {
await StorageLocationEditorDialog.show();
}
}
class ProductEditorDialog extends GetView<ProductEditorController> {
const ProductEditorDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: controller.editedProduct != null
? const Text("Редактируем продукт")
: const Text("Новый продукт"),
content: SizedBox(
width: size.width * 0.5,
height: size.height * 0.9,
child: ListView(
children: [
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(label: Text("Наименование")),
),
Row(
children: [
Expanded(
child: Obx(
() => DropdownButtonFormField<ProductCategoryData>(
items: [
for (final item in controller.categories)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
ProductCategoryIcons.fromName(item.icon).icon,
),
const SizedBox(width: 8),
Text(item.name),
],
),
),
],
value: controller.category.value,
isExpanded: true,
hint: const Text("Категория"),
onChanged: (cat) {
if (cat != null) {
controller.category.value = cat;
}
},
),
),
),
IconButton(
onPressed: () => controller.newCategory(),
icon: const Icon(Icons.add),
),
],
),
Row(
children: [
Expanded(
child: Obx(
() => DropdownButtonFormField<StorageLocationData>(
items: [
for (final item in controller.storages)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
StorageLocationIcon.fromName(item.icon).icon,
),
const SizedBox(width: 8),
Text(item.name),
],
),
),
],
value: controller.storage.value,
isExpanded: true,
hint: const Text("Место хранения"),
onChanged: (st) {
if (st != null) {
controller.storage.value = st;
}
},
),
),
),
IconButton(
onPressed: () => controller.newStorage(),
icon: const Icon(Icons.add),
),
],
),
TextFormField(
controller: controller.quantityController,
decoration: const InputDecoration(label: Text("Количество")),
),
TextFormField(
controller: controller.unitController,
decoration:
const InputDecoration(label: Text("Единица измерения")),
),
Obx(
() => TextFormField(
readOnly: true,
controller: TextEditingController(
text: controller.expiryDate.value?.simpleDateFormat,
),
onTap: () async {
final dt = await showDatePicker(
context: context,
initialDate: controller.expiryDate.value ??
DateTime.now().add(1.days),
firstDate: DateTime.now(),
lastDate: DateTime(9999),
);
if (dt != null) {
controller.expiryDate.value = dt;
}
},
decoration: const InputDecoration(
label: Text("Годен до"),
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Отмена"),
),
TextButton(
onPressed: () => controller.save(),
child: const Text("Сохранить"),
),
],
);
}
static Future<void> show({
required RxList<ProductCategoryData> categories,
required RxList<StorageLocationData> storages,
ProductData? editedProduct,
StorageLocationData? preselectedStorage,
}) async {
final controller = Get.put(ProductEditorController());
controller.setCategories(categories);
controller.setStorages(storages);
if (preselectedStorage != null) {
controller.setStorage(preselectedStorage);
}
if (editedProduct != null) {
controller.setEditedProduct(editedProduct);
}
const transDuration = Duration(milliseconds: 300);
await Get.dialog(
const ProductEditorDialog(),
id: "ProductEditorDialog",
name: "ProductEditorDialog",
transitionDuration: transDuration,
);
await (transDuration + 10.milliseconds).delay();
Get.delete<ProductEditorController>();
}
}

View file

@ -0,0 +1,286 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/pages/main/dialogs/category_editor_dialog.dart';
import 'package:groceries_manager/pages/main/dialogs/storage_location_editor_dialog.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
import '../../../db/database.dart';
class ShoppingItemEditorController extends GetxController {
final editedItem = Rxn<ShoppingListItemData>();
late final RxList<ProductCategoryData> categories;
late final RxList<StorageLocationData> storages;
final nameController = TextEditingController();
final category = Rxn<ProductCategoryData>();
final storage = Rxn<StorageLocationData>();
final quantityController = TextEditingController();
final unitController = TextEditingController();
void setEditedItem(
(ShoppingListItemData, ProductCategoryData, StorageLocationData) esi) {
final (item, category, storage) = esi;
editedItem.value = item;
nameController.text = item.name;
this.category.value = category;
this.storage.value = storage;
quantityController.text = item.quantity.toString();
unitController.text = item.unit;
}
void fromProduct(ProductData product) {
nameController.text = product.name;
category.value = categories.firstWhere((e) => e.id == product.category);
storage.value = storages.firstWhere((e) => e.id == product.storage);
quantityController.text = product.quantity.toString();
unitController.text = product.unit;
}
Future<void> save({bool clone = false}) async {
final name = nameController.text.trim();
if (name.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Имя категории не может быть пустым",
);
return;
}
final category = this.category.value;
if (category == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Необходимо выбрать категорию продукта",
);
return;
}
final storage = this.storage.value;
if (storage == null) {
ToasterService.to.error(
title: "Ошибка",
message: "Необходимо выбрать место хранения продукта",
);
return;
}
final quantity = quantityController.text;
if (!quantity.isNum) {
ToasterService.to.error(
title: "Ошибка",
message: "Кол-во продукта должно быть числом",
);
return;
}
final unit = unitController.text;
if (unit.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Единица измерения продукта должна быть указана",
);
return;
}
if (editedItem.value != null && !clone) {
await DBService.to.db.updateShoppingListItem(
id: editedItem.value!.id,
name: name,
category: category,
storage: storage,
quantity: double.parse(quantity),
unit: unit,
);
ToasterService.to.success(
title: "Успех",
message: "Запись списка покупок изменена: "
"'$name' - $quantity $unit",
);
} else {
await DBService.to.db.addShoppingListItem(
name: name,
category: category,
storage: storage,
quantity: double.parse(quantity),
unit: unit,
);
ToasterService.to.success(
title: "Успех",
message: "Запись добавлена в список покупок: "
"'$name' - $quantity $unit",
);
}
cancel();
}
void cancel() {
Get.backLegacy();
}
Future<void> newCategory() async {
await CategoryEditorDialog.show();
}
Future<void> newStorage() async {
await StorageLocationEditorDialog.show();
}
}
class ShoppingItemEditorDialog extends GetView<ShoppingItemEditorController> {
const ShoppingItemEditorDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: controller.editedItem.value != null
? const Text("Редактируем запись списка покупок")
: const Text("Новая запись списка покупок"),
content: SizedBox(
width: size.width * 0.5,
height: size.height * 0.9,
child: ListView(
children: [
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(label: Text("Наименование")),
),
Row(
children: [
Expanded(
child: Obx(
() => DropdownButtonFormField<ProductCategoryData>(
items: [
for (final item in controller.categories)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
ProductCategoryIcons.fromName(item.icon).icon,
),
const SizedBox(width: 8),
Text(item.name),
],
),
),
],
value: controller.category.value,
isExpanded: true,
hint: const Text("Категория"),
onChanged: (prod) {
if (prod != null) {
controller.category.value = prod;
}
},
),
),
),
IconButton(
onPressed: () => controller.newCategory(),
icon: const Icon(Icons.add),
),
],
),
Row(
children: [
Expanded(
child: Obx(
() => DropdownButtonFormField<StorageLocationData>(
items: [
for (final item in controller.storages)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
StorageLocationIcon.fromName(item.icon).icon,
),
const SizedBox(width: 8),
Text(item.name),
],
),
),
],
value: controller.storage.value,
isExpanded: true,
hint: const Text("Место хранения"),
onChanged: (item) {
if (item != null) {
controller.storage.value = item;
}
},
),
),
),
IconButton(
onPressed: () => controller.newStorage(),
icon: const Icon(Icons.add),
),
],
),
TextFormField(
controller: controller.quantityController,
decoration: const InputDecoration(label: Text("Количество")),
),
TextFormField(
controller: controller.unitController,
decoration:
const InputDecoration(label: Text("Единица измерения")),
),
],
),
),
actions: [
if (controller.editedItem.value != null)
TextButton(
onPressed: () => controller.save(clone: true),
child: const Text("Клонировать"),
),
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Отмена"),
),
TextButton(
onPressed: () => controller.save(),
child: const Text("Сохранить"),
),
],
);
}
static Future<void> show({
required RxList<ProductCategoryData> categories,
required RxList<StorageLocationData> storages,
(
ShoppingListItemData,
ProductCategoryData,
StorageLocationData
)? editedItem,
ProductData? fromProduct,
}) async {
final controller = Get.put(ShoppingItemEditorController());
controller.categories = categories;
controller.storages = storages;
if (editedItem != null) {
controller.setEditedItem(editedItem);
}
if (fromProduct != null) {
controller.fromProduct(fromProduct);
}
await Get.dialog(
const ShoppingItemEditorDialog(),
id: "ShoppingItemEditorDialog",
name: "ShoppingItemEditorDialog",
);
Get.delete<ShoppingItemEditorController>();
}
}

View file

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
import 'package:icons_plus/icons_plus.dart';
import '../../../db/database.dart';
import '../../../models/enums/temperature_mode.dart';
class StorageLocationEditorController extends GetxController {
final editedStorageLocation = Rxn<StorageLocationData>();
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final temperatureMode = TemperatureMode.unspecified.obs;
final storageLocationIcon = StorageLocationIcon.box_2.obs;
final setAsDefault = false.obs;
void setEditedStorageLocation(StorageLocationData ecd) {
editedStorageLocation.value = ecd;
nameController.text = ecd.name;
descriptionController.text = ecd.description;
temperatureMode.value = TemperatureMode.fromName(ecd.temperatureMode);
storageLocationIcon.value = StorageLocationIcon.fromName(ecd.icon);
}
Future<void> save() async {
final name = nameController.text.trim();
if (name.isEmpty) {
ToasterService.to.error(
title: "Ошибка",
message: "Имя места хранения не может быть пустым",
);
return;
}
final description = descriptionController.text.trim();
if (editedStorageLocation.value != null) {
final updateId = await DBService.to.db.updateStorageLocation(
id: editedStorageLocation.value!.id,
name: name,
description: description,
temperatureMode: temperatureMode.value,
icon: storageLocationIcon.value.name,
);
if (setAsDefault.value) {
await DBService.to.db.switchDefault(updateId);
}
ToasterService.to.success(
title: "Успех",
message: "Место хранения '$name' изменено",
);
} else {
final newId = await DBService.to.db.addStorageLocation(
name: name,
description: description,
temperatureMode: temperatureMode.value,
icon: storageLocationIcon.value.name,
);
if (setAsDefault.value) {
await DBService.to.db.switchDefault(newId);
}
ToasterService.to.success(
title: "Успех",
message: "Место хранения '$name' создано",
);
}
cancel();
}
void cancel() {
Get.backLegacy();
}
}
class StorageLocationEditorDialog
extends GetView<StorageLocationEditorController> {
const StorageLocationEditorDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: controller.editedStorageLocation.value != null
? const Text("Редактируем место хранения")
: const Text("Новое место хранения"),
content: SizedBox(
width: size.width * 0.5,
height: size.height * 0.9,
child: ListView(
children: [
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(label: Text("Название")),
),
TextFormField(
controller: controller.descriptionController,
decoration: const InputDecoration(label: Text("Описание")),
),
Obx(
() => DropdownButtonFormField<TemperatureMode>(
items: [
for (final tempMode in TemperatureMode.values)
DropdownMenuItem(
value: tempMode,
child: Text(tempMode.betterName),
),
],
value: controller.temperatureMode.value,
isExpanded: true,
icon: const Icon(FontAwesome.temperature_empty_solid),
hint: const Text("Температурный режим"),
onChanged: (tempMode) {
if (tempMode != null) {
controller.temperatureMode.value = tempMode;
}
},
),
),
Obx(
() => DropdownButtonFormField<StorageLocationIcon>(
items: [
for (final item in StorageLocationIcon.values)
DropdownMenuItem(
value: item,
child: Row(
children: [
Icon(
StorageLocationIcon.fromName(item.name).icon,
),
const SizedBox(width: 8),
Text(item.betterName),
],
),
),
],
value: controller.storageLocationIcon.value,
isExpanded: true,
hint: const Text("Иконка"),
onChanged: (slIcon) {
if (slIcon != null) {
controller.storageLocationIcon.value = slIcon;
}
},
),
),
Obx(
() => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: controller.setAsDefault.value,
onChanged: (newVal) {
controller.setAsDefault.value = newVal;
},
title: const Text("Назначить основным"),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Отмена"),
),
TextButton(
onPressed: () => controller.save(),
child: const Text("Сохранить"),
),
],
);
}
static Future<void> show({
StorageLocationData? esl,
}) async {
final controller = Get.put(StorageLocationEditorController());
if (esl != null) {
controller.setEditedStorageLocation(esl);
}
await Get.dialog(
const StorageLocationEditorDialog(),
id: "StorageLocationEditorDialog",
name: "StorageLocationEditorDialog",
);
Get.delete<StorageLocationEditorController>();
}
}

View file

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/pages/main/home_controller.dart';
import 'package:groceries_manager/services/db_service.dart';
import 'package:groceries_manager/services/toaster_service.dart';
import 'package:groceries_manager/utils/get_interface_extension.dart';
import '../../../db/database.dart';
class UserManagerController extends GetxController {
final users = <UserData>[].obs;
@override
void onInit() {
super.onInit();
refreshData();
}
Future<void> refreshData() async {
final users = await DBService.to.db.getUsers();
this.users.clear();
this.users.addAll(users);
}
void cancel() {
Get.backLegacy();
}
Future<void> changeUserPassword(UserData item) async {
final newPassword = await Get.prompt(
title: "Изменение пароля для '${item.login}'",
label: "Новый пароль",
stringValidator: (value) {
if (RegExp(r'[^а-яА-Яa-zA-Z0-9]').hasMatch(value ?? "")) {
return "Пароль не соответствует требованиям: а-яА-Яa-zA-Z0-9";
}
return null;
},
);
if (newPassword == null) {
return;
}
await DBService.to.db.updateUserPassword(
item,
password: newPassword,
);
refreshData();
}
Future<void> deleteUser(UserData item) async {
final currentUser = Get<HomeController>().user.value;
if (currentUser.id == item.id) {
ToasterService.to.error(
title: "Ошибка",
message: "Нельзя удалить самого себя!",
);
return;
}
final confirmed = await Get.confirm(
title: "Внимание!",
content: "Удаление пользователя необратимо!\n"
"Точно хотите удалить '${item.login}'?",
);
if (!confirmed) return;
final deleted = await DBService.to.db.deleteUser(item);
if (!deleted) {
ToasterService.to.error(
title: "Ошибка",
message: "Нельзя единственного пользователя!",
);
}
refreshData();
}
}
class UserManagerDialog extends GetView<UserManagerController> {
const UserManagerDialog({super.key});
@override
Widget build(BuildContext context) {
final size = Get.size;
return AlertDialog(
title: const Text("Управление категориями"),
content: SizedBox(
width: size.width * 0.9,
height: size.height * 0.9,
child: SingleChildScrollView(
child: Obx(
() => Table(
border: TableBorder.all(color: Colors.blueGrey.withAlpha(50)),
children: [
const TableRow(
children: [
TableCell(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Логин"),
),
),
TableCell(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Частичный хэш пароля"),
),
),
TableCell(
child: SizedBox(),
),
],
),
for (final item in controller.users)
TableRow(
key: ValueKey(item),
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(item.login),
),
),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text("${item.password.substring(0, 20)}..."),
),
),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
ElevatedButton.icon(
onPressed: () =>
controller.changeUserPassword(item),
icon: const Icon(Icons.edit_rounded),
label: const Text("Изменить пароль"),
),
const SizedBox(height: 4),
ElevatedButton.icon(
onPressed: () => controller.deleteUser(item),
icon: const Icon(Icons.delete_forever),
label: const Text("Удалить"),
),
],
),
),
),
],
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => controller.cancel(),
child: const Text("Закрыть"),
),
],
);
}
static Future<void> show({
UserData? ecd,
}) async {
Get.put(UserManagerController());
await Get.dialog(
const UserManagerDialog(),
id: "UserManagerDialog",
name: "UserManagerDialog",
);
Get.delete<UserManagerController>();
}
}

View file

@ -0,0 +1,313 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:get/get.dart';
import '../../db/database.dart';
import '../../services/db_service.dart';
import '../../services/toaster_service.dart';
import '../../utils/get_interface_extension.dart';
import 'dialogs/category_manager_dialog.dart';
import 'dialogs/product_editor_dialog.dart';
import 'dialogs/shopping_item_editor_dialog.dart';
import 'dialogs/storage_location_editor_dialog.dart';
import 'dialogs/user_manager_dialog.dart';
class HomeController extends GetxController {
final soonExpiries =
<(ProductData, ProductCategoryData, StorageLocationData)>[].obs;
final stockProducts =
<(StorageLocationData, List<(ProductData, ProductCategoryData)>)>[].obs;
final shoppingList =
<(ShoppingListItemData, ProductCategoryData, StorageLocationData)>[].obs;
final groupedShoppingList = <bool,
List<
(
ShoppingListItemData,
ProductCategoryData,
StorageLocationData
)>>{}.obs;
final products = <ProductData>[].obs;
final categories = <ProductCategoryData>[].obs;
final storages = <StorageLocationData>[].obs;
late final StreamSubscription psSub;
late final StreamSubscription seSub;
late final StreamSubscription slSub;
late final StreamSubscription pcSub;
final expandedStorages = <bool>[].obs;
late final Rx<UserData> user;
@override
void onInit() {
super.onInit();
refreshAll();
psSub = DBService.to.db.productsSubscription.listen((data) {
refreshStockProducts();
});
seSub = DBService.to.db.soonExpirySubscription.listen((data) {
refreshSoonExpiries();
});
slSub = DBService.to.db.shoppingListSubscription.listen((data) {
refreshShoppingList();
});
pcSub = DBService.to.db.productsCategoriesSubscription.listen((data) {
refreshCategoriesList();
});
}
@override
void onClose() {
psSub.cancel();
seSub.cancel();
slSub.cancel();
pcSub.cancel();
super.onClose();
}
Future<void> refreshAll() async {
await refreshCategoriesList();
await refreshStockProducts();
await refreshSoonExpiries();
await refreshShoppingList();
user.value = await DBService.to.db.getUser(user.value);
}
Future<void> refreshSoonExpiries() async {
final sExps = await DBService.to.db.getSoonExpiryProducts();
print(sExps);
soonExpiries.clear();
soonExpiries.addAll(sExps);
}
Future<void> refreshStockProducts() async {
final stock = await DBService.to.db.getStorageLocations();
storages.clear();
storages.addAll(stock.map((e) => e.$1));
final expandedDiff = expandedStorages.length - storages.length;
if (expandedDiff < 0) {
expandedStorages.addAll(List.generate(expandedDiff.abs(), (idx) => true));
} else if (expandedDiff > 0) {
expandedStorages.value = expandedStorages.sublist(
0,
expandedStorages.length - expandedDiff,
);
}
products.clear();
products.addAll(stock.map((e) => e.$2).flattened.map((e) => e.$1));
stockProducts.clear();
stockProducts.addAll(stock);
}
Future<void> refreshShoppingList() async {
final stock = await DBService.to.db.getShoppingList();
shoppingList.clear();
shoppingList.addAll(stock);
groupedShoppingList.clear();
groupedShoppingList[true] = [];
groupedShoppingList[false] = [];
for (var e in shoppingList) {
groupedShoppingList[e.$1.isPurchased]!.add(e);
}
}
Future<void> refreshCategoriesList() async {
final categories = await DBService.to.db.getProductCategories();
this.categories.clear();
this.categories.addAll(categories);
}
void addNewProduct([StorageLocationData? psld]) {
ProductEditorDialog.show(
categories: categories,
storages: storages,
preselectedStorage: psld,
);
}
Future<void> editProduct(ProductData prod) async {
await ProductEditorDialog.show(
categories: categories,
storages: storages,
editedProduct: prod,
);
refreshAll();
}
Future<void> deleteProduct(ProductData prod) async {
final confirmed = await Get.confirm(
title: "Внимание!",
content: "Удаление продукта необратимо!\n"
"Точно хотите удалить '${prod.name} - ${prod.quantity} ${prod.unit}'?",
);
if (!confirmed) return;
await DBService.to.db.deleteProduct(prod);
ToasterService.to.success(
title: "Успех",
message: "Продукт '${prod.name}' удалён",
);
refreshAll();
}
Future<void> editStorageLocation(StorageLocationData storage) async {
await StorageLocationEditorDialog.show(esl: storage);
refreshAll();
}
Future<void> deleteStorageLocation(StorageLocationData storage) async {
final confirmed = await Get.confirm(
title: "Внимание!",
content: "Удаление места хранения необратимо!\n"
"Точно хотите удалить '${storage.name}'?",
);
if (!confirmed) return;
for (final (storageItem, items) in stockProducts) {
if (storageItem.id == storage.id && items.isNotEmpty) {
ToasterService.to.error(
title: "Ошибка",
message:
"Нельзя удалить место хранения, к которому привязаны предметы!",
);
return;
}
}
await DBService.to.db.deleteStorageLocation(storage);
ToasterService.to.success(
title: "Успех",
message: "Место хранения '${storage.name}' удалено",
);
refreshAll();
}
Future<void> addNewShoppingItem([ProductData? ppd]) async {
await ShoppingItemEditorDialog.show(
categories: categories,
storages: storages,
);
refreshShoppingList();
}
Future<void> switchShoppingItem(
ShoppingListItemData item,
bool isPurchased,
) async {
await DBService.to.db.updateShoppingListItem(
id: item.id,
isPurchased: isPurchased,
);
refreshShoppingList();
}
Future<void> editShoppingItem(
ShoppingListItemData item,
ProductCategoryData category,
StorageLocationData location,
) async {
await ShoppingItemEditorDialog.show(
categories: categories,
storages: storages,
editedItem: (item, category, location),
);
refreshShoppingList();
}
Future<void> deleteShoppingItem(ShoppingListItemData item) async {
final confirmed = await Get.confirm(
title: "Внимание!",
content: "Удаление записи из списка покупок необратимо!\n"
"Точно хотите удалить '${item.name} - ${item.quantity} ${item.unit}'?",
);
if (!confirmed) return;
await DBService.to.db.deleteShoppingListItem(item);
refreshShoppingList();
}
Future<void> saveShoppingItem(ShoppingListItemData item,
ProductCategoryData category, StorageLocationData location) async {
final expiryDate = await showDatePicker(
context: Get.context!,
helpText: "Срок годности продукта из списка покупок",
initialDate: DateTime.now().add(1.days),
firstDate: DateTime.now(),
lastDate: DateTime(9999),
);
if (expiryDate == null) {
return;
}
await DBService.to.db.deleteShoppingListItem(item);
await DBService.to.db.addProduct(
name: item.name,
category: category,
storage: location,
quantity: item.quantity,
unit: item.unit,
expiryDate: expiryDate,
barcode: "",
);
ToasterService.to.success(
title: "Успех",
message: "Продукт '${item.name}' перемещён "
"из списка покупок в список продуктов",
);
}
Future<void> addNewStorage() async {
await StorageLocationEditorDialog.show();
refreshStockProducts();
}
Future<void> addToShoppingList(ProductData p) async {
await ShoppingItemEditorDialog.show(
categories: categories,
storages: storages,
fromProduct: p,
);
refreshShoppingList();
}
void setUser(UserData user) {
this.user = Rx<UserData>(user);
}
Future<void> openCategoryManagerDialog() async {
await CategoryManagerDialog.show();
refreshAll();
}
Future<void> openUserManagerDialog() async {
await UserManagerDialog.show();
refreshAll();
}
Future<void> logout() async {
Get.offAllNamed("/login");
Get.delete<HomeController>(force: true);
}
}

View file

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'home_controller.dart';
import 'widgets/shopping_list_card.dart';
import 'widgets/soon_expiries_card.dart';
import 'widgets/stock_products_card.dart';
class HomePage extends GetView<HomeController> {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Управление продуктами"),
actions: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Obx(
() => Text("Привет, ${controller.user.value.login}!"),
),
const SizedBox(width: 8),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
onTap: () => controller.openCategoryManagerDialog(),
child: const ListTile(
title: Text("Управление категориями"),
),
),
PopupMenuItem(
onTap: () => controller.openUserManagerDialog(),
child: const ListTile(
title: Text("Управление пользователями"),
),
),
const PopupMenuItem(
padding: EdgeInsets.zero,
enabled: false,
child: PopupMenuDivider(),
),
PopupMenuItem(
onTap: () => controller.logout(),
child: const ListTile(
title: Text("Выйти"),
),
),
],
),
],
),
],
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Card.filled(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.timelapse),
title: const Text("Скоро испортится!"),
subtitle: const Text("Надо о них позаботиться"),
trailing: IconButton(
onPressed: () => controller.refreshSoonExpiries(),
icon: const Icon(Icons.refresh_rounded),
),
),
const SoonExpiriesCard(),
],
),
),
),
Expanded(
child: Card.filled(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.shelves),
title: const Text("Продукты в наличии"),
subtitle: const Text("Эти у нас есть"),
trailing: IconButton(
onPressed: () => controller.addNewStorage(),
icon: const Icon(Icons.add_rounded),
),
),
const StockProductsCard()
],
),
),
),
Expanded(
child: Card.filled(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.shopping_bag_outlined),
title: const Text("Список покупок"),
subtitle: const Text("Это надо купить"),
trailing: IconButton(
onPressed: () => controller.addNewShoppingItem(),
icon: const Icon(Icons.add_rounded),
),
),
const ShoppingListCard()
],
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/common/icons.dart';
import 'package:groceries_manager/db/database.dart';
import 'package:groceries_manager/utils/format_datetime_extension.dart';
import 'package:groceries_manager/utils/pluralize_int_extension.dart';
class ProductWidget extends GetWidget {
final ProductData prod;
final ProductCategoryData cat;
final StorageLocationData store;
final void Function(ProductData) onEditClicked;
final void Function(ProductData) onDeleteClicked;
final void Function(ProductData) onAddToCartClicked;
const ProductWidget({
super.key,
required this.prod,
required this.cat,
required this.store,
required this.onEditClicked,
required this.onDeleteClicked,
required this.onAddToCartClicked,
});
@override
Widget build(BuildContext context) {
print(prod);
return Card.outlined(
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
top: 4,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
key: ValueKey("se-item-${prod.id}"),
contentPadding: EdgeInsets.zero,
title: Text(prod.name),
subtitle: Text(
"Куплено: ${prod.purchaseDate?.simpleDateFormat}\n"
"Испортится через ${() {
final diff =
prod.expiryDate!.difference(DateTime.now());
if (diff.inDays == 0) {
return diff.inHours.pluralize(
name: "час",
absent: "часа",
absentMul: "часов",
);
}
return (diff.inDays + (diff.inHours > 24 ? 1 : 0))
.pluralize(
name: "день",
absent: "дня",
absentMul: "дней",
);
}()}",
),
),
const SizedBox(height: 16),
Chip(
avatar: Icon(
ProductCategoryIcons.fromName(cat.icon).icon,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
label: Text(cat.name),
),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
onPressed: () => onAddToCartClicked(prod),
icon: const Icon(Icons.add_shopping_cart),
),
IconButton(
onPressed: () => onEditClicked(prod),
icon: const Icon(Icons.edit),
),
IconButton(
onPressed: () => onDeleteClicked(prod),
icon: const Icon(Icons.delete_forever),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/pages/main/home_controller.dart';
class ShoppingListCard extends GetView<HomeController> {
const ShoppingListCard({super.key});
@override
Widget build(BuildContext context) {
return Expanded(
child: Card(
child: Obx(
() => controller.shoppingList.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_rounded, size: 64),
SizedBox(height: 16),
Text("Всё в наличии!"),
],
),
)
: Obx(
() => ListView.builder(
itemCount: controller.shoppingList.length + 3,
itemBuilder: (context, index) {
if (index == 0) {
return const ListTile(
title: Text("Купить"),
);
}
index -= 1;
final toBuyItems = controller.groupedShoppingList[false]!;
if (index < toBuyItems.length) {
final (item, category, location) = toBuyItems[index];
return Row(
children: [
Expanded(
child: ListTile(
dense: true,
key: ValueKey("slc-cbi-${item.id}-true"),
title: Text(item.name),
subtitle: Text("${item.quantity} ${item.unit}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: false,
onChanged: (en) {
if (en == true) {
controller.switchShoppingItem(
item,
true,
);
}
},
),
const SizedBox(width: 8),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
onTap: () {
controller.editShoppingItem(
item,
category,
location,
);
},
child: const ListTile(
title: Text("Редактировать"),
),
),
PopupMenuItem(
onTap: () {
controller.deleteShoppingItem(item);
},
child: const ListTile(
title: Text("Удалить"),
),
),
],
),
],
),
),
),
],
);
}
index -= toBuyItems.length;
if (index == 0) {
return const Divider();
}
index -= 1;
if (index == 0) {
return const ListTile(
title: Text("Куплено"),
);
}
index -= 1;
final boughtItems = controller.groupedShoppingList[true]!;
if (index < boughtItems.length) {
final (item, category, location) = boughtItems[index];
return Row(
children: [
Expanded(
child: ListTile(
dense: true,
key: ValueKey("slc-cbi-${item.id}-true"),
title: Text(item.name),
subtitle: Text("${item.quantity} ${item.unit}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: true,
onChanged: (en) {
if (en == false) {
controller.switchShoppingItem(
item,
false,
);
}
},
),
const SizedBox(width: 8),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
onTap: () {
controller.saveShoppingItem(
item,
category,
location,
);
},
child: const ListTile(
title: Text("Применить"),
),
),
],
),
],
),
),
),
],
);
}
return const ListTile(title: Text("UNREACHABLE"));
},
),
),
),
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:groceries_manager/pages/main/home_controller.dart';
import 'package:groceries_manager/pages/main/widgets/product_widget.dart';
class SoonExpiriesCard extends GetView<HomeController> {
const SoonExpiriesCard({super.key});
@override
Widget build(BuildContext context) {
return Expanded(
child: Card(
child: Obx(
() => controller.soonExpiries.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sunny,
size: 64,
),
SizedBox(height: 16),
Text("Ура, всем продуктам хорошо!"),
],
),
)
: ListView(
children: [
for (final (prod, cat, store) in controller.soonExpiries)
ProductWidget(
key: ValueKey("se-holder-${prod.id}"),
prod: prod,
cat: cat,
store: store,
onEditClicked: (p) => controller.editProduct(p),
onDeleteClicked: (p) => controller.deleteProduct(p),
onAddToCartClicked: (p) =>
controller.addToShoppingList(p),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../common/icons.dart';
import '../../../utils/pluralize_int_extension.dart';
import '../home_controller.dart';
import 'storages_list_item.dart';
class StockProductsCard extends GetView<HomeController> {
const StockProductsCard({super.key});
@override
Widget build(BuildContext context) {
return Expanded(
child: Card(
clipBehavior: Clip.antiAlias,
child: Obx(
() => controller.stockProducts.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.question_mark_rounded,
size: 64,
),
SizedBox(height: 16),
Text("Пустовато, что-то упустили?"),
],
),
)
: const SingleChildScrollView(child: ItemsList()),
),
),
);
}
}
class ItemsList extends GetView<HomeController> {
const ItemsList({super.key});
@override
Widget build(BuildContext context) {
return Obx(
() => ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
controller.expandedStorages[index] = isExpanded;
},
children: [
for (final (idx, (storage, items))
in controller.stockProducts.indexed)
ExpansionPanel(
isExpanded: controller.expandedStorages[idx],
headerBuilder: (context, isExpanded) {
return ListTile(
leading: Icon(
StorageLocationIcon.fromName(storage.icon).icon,
),
title: Text(storage.name),
);
},
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (storage.description.isNotEmpty)
ListTile(
dense: true,
trailing: const Icon(Icons.description),
title: Text(storage.description),
),
ListTile(
dense: true,
trailing: const Icon(Icons.numbers),
title: Text(items.length.pluralize(
name: "предмет",
absent: "предмета",
absentMul: "предметов",
)),
),
ListTile(
dense: true,
onTap: () => controller.editStorageLocation(storage),
trailing: const Icon(Icons.edit),
title: const Text("Редактировать"),
),
ListTile(
dense: true,
onTap: () => controller.deleteStorageLocation(storage),
trailing: const Icon(Icons.delete_forever),
title: const Text("Удалить"),
),
ListTile(
dense: true,
onTap: () => controller.addNewProduct(storage),
trailing: const Icon(Icons.add),
title: const Text("Добавить продукт"),
),
const Divider(),
if (items.isEmpty)
const ListTile(
title: Text("Пусто :("),
),
for (final (prod, cat) in items)
StoragesListItem(
key: ValueKey(prod.hashCode + cat.hashCode),
prod: prod,
cat: cat,
),
],
),
)
],
),
);
}
}

View file

@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../common/icons.dart';
import '../../../db/database.dart';
import '../../../utils/format_datetime_extension.dart';
import '../../../utils/pluralize_int_extension.dart';
import '../home_controller.dart';
class StoragesListItem extends GetWidget<HomeController> {
final ProductData prod;
final ProductCategoryData cat;
const StoragesListItem({
super.key,
required this.prod,
required this.cat,
});
@override
Widget build(BuildContext context) {
return Card.outlined(
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
top: 4,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
key: ValueKey("se-item-${prod.id}"),
contentPadding: EdgeInsets.zero,
title: Text(prod.name),
subtitle: Text(
"Куплено: ${prod.purchaseDate?.simpleDateFormat}\n"
"Испортится через ${() {
final diff =
prod.expiryDate!.difference(DateTime.now());
if (diff.inDays == 0) {
return diff.inHours.pluralize(
name: "час",
absent: "часа",
absentMul: "часов",
);
}
return (diff.inDays + (diff.inHours > 24 ? 1 : 0))
.pluralize(
name: "день",
absent: "дня",
absentMul: "дней",
);
}()}",
),
),
const SizedBox(height: 16),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
Chip(
avatar: Icon(
ProductCategoryIcons.fromName(cat.icon).icon,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
label: Text(cat.name),
),
Chip(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
label: Text("${prod.quantity} ${prod.unit}"),
),
],
),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
onPressed: () => controller.addToShoppingList(prod),
icon: const Icon(Icons.add_shopping_cart),
),
IconButton(
onPressed: () => controller.editProduct(prod),
icon: const Icon(Icons.edit),
),
IconButton(
onPressed: () => controller.deleteProduct(prod),
icon: const Icon(Icons.delete_forever),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,18 @@
import 'package:get/get.dart';
class RedirectController extends GetxController {
final String redirectTo;
RedirectController({
required this.redirectTo,
});
@override
void onReady() {
super.onReady();
500.milliseconds.delay(() {
Get.offAllNamed(redirectTo);
});
}
}

View file

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'redirect_controller.dart';
class RedirectPage extends GetView<RedirectController> {
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Перенаправляю..."),
],
),
),
);
}
}

View file

@ -0,0 +1,17 @@
import 'package:get/get.dart';
import 'package:groceries_manager/db/database.dart';
class DBService extends GetxService {
static DBService get to => Get.find();
late final AppDatabase db;
DBService();
Future<void> init() async {
db = AppDatabase();
await db.doWhenOpened((_) {
print("Database loaded");
});
}
}

View file

@ -0,0 +1,100 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:toastification/toastification.dart';
class ToasterService extends GetxService {
static ToasterService get to => Get.find();
void show({
required String title,
required String message,
ToastificationType type = ToastificationType.info,
ToastificationStyle style = ToastificationStyle.flat,
AlignmentGeometry alignment = Alignment.bottomLeft,
ToastificationCallbacks callbacks = const ToastificationCallbacks(),
Duration? autoCloseDuration = const Duration(seconds: 2),
}) {
if (Get.context?.mounted != true) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
toastification.show(
context: Get.context,
type: type,
style: style,
title: Text(title),
description: Text(message),
alignment: alignment,
autoCloseDuration: autoCloseDuration,
closeOnClick: false,
callbacks: callbacks,
);
});
}
void info({
required String title,
required String message,
ToastificationStyle style = ToastificationStyle.flat,
AlignmentGeometry alignment = Alignment.bottomLeft,
ToastificationCallbacks callbacks = const ToastificationCallbacks(),
Duration? autoCloseDuration = const Duration(seconds: 2),
}) {
show(
title: title,
message: message,
style: style,
alignment: alignment,
callbacks: callbacks,
autoCloseDuration: autoCloseDuration,
);
}
void waring({
required String title,
required String message,
ToastificationStyle style = ToastificationStyle.flat,
AlignmentGeometry alignment = Alignment.bottomLeft,
ToastificationCallbacks callbacks = const ToastificationCallbacks(),
Duration? autoCloseDuration = const Duration(seconds: 2),
}) {
show(
title: title,
message: message,
style: style,
alignment: alignment,
callbacks: callbacks,
autoCloseDuration: autoCloseDuration,
);
}
void success({
required String title,
required String message,
ToastificationStyle style = ToastificationStyle.flat,
AlignmentGeometry alignment = Alignment.bottomLeft,
ToastificationCallbacks callbacks = const ToastificationCallbacks(),
Duration? autoCloseDuration = const Duration(seconds: 2),
}) {
show(
title: title,
message: message,
type: ToastificationType.success,
);
}
void error({
required String title,
required String message,
ToastificationStyle style = ToastificationStyle.flat,
AlignmentGeometry alignment = Alignment.bottomLeft,
ToastificationCallbacks callbacks = const ToastificationCallbacks(),
Duration? autoCloseDuration = const Duration(seconds: 2),
}) {
show(
title: title,
message: message,
type: ToastificationType.error,
);
}
}

View file

@ -0,0 +1,5 @@
extension FormatDatetimeExtension on DateTime {
String get simpleDateFormat {
return "${day < 10 ? "0" : ""}$day.$month.$year";
}
}

View file

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
extension GetInterfaceExtension on GetInterface {
Future<bool> confirm({
required String title,
required String content,
String yes = "Да",
String no = "Нет",
}) async {
final result = await Get.defaultDialog(
title: title,
content: Text(content),
textConfirm: yes,
textCancel: no,
onConfirm: () => Get.backLegacy(result: true),
onCancel: () => Get.backLegacy(result: false),
);
return result == true;
}
Future<String?> prompt({
required String title,
String message = "",
String hintText = "",
String label = "",
String defaultValue = "",
String positiveLabel = "ОК",
String negativeLabel = "Отмена",
int? maxLength,
int maxLines = 1,
Widget? prefix,
required String? Function(String? x) stringValidator,
}) async {
final controller = TextEditingController(text: defaultValue);
final value = Get.dialog<String>(
AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(message),
),
TextFormField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
filled: true,
prefix: prefix,
label: Text(label),
),
maxLength: maxLength,
maxLines: maxLines,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: stringValidator,
)
],
),
actions: [
ElevatedButton(
onPressed: () {
if (stringValidator(controller.value.text) == null) {
Get.backLegacy(result: controller.value.text);
}
},
child: Text(positiveLabel),
),
ElevatedButton(
onPressed: () => Get.backLegacy(result: null),
child: Text(negativeLabel),
),
],
),
barrierDismissible: false,
useSafeArea: true,
);
return value;
}
}

View file

@ -0,0 +1,24 @@
extension PluralizeIntExtension on int {
String pluralize({
required String name,
required String absent,
required String absentMul,
}) {
final plurality = this % 10 == 1 && this % 100 != 11
? 0
: this % 10 >= 2 &&
this % 10 <= 4 &&
(this % 100 < 10 || this % 100 >= 20)
? 1
: 2;
switch (plurality) {
case 1:
return "$this $absent";
case 2:
return "$this $absentMul";
default:
return "$this $name";
}
}
}