This commit is contained in:
Phuoc Nguyen
2025-10-10 16:38:07 +07:00
parent e5b247d622
commit b94c158004
177 changed files with 25080 additions and 152 deletions

View File

@@ -0,0 +1,37 @@
import 'package:hive_ce/hive.dart';
import '../models/category_model.dart';
/// Category local data source using Hive
abstract class CategoryLocalDataSource {
Future<List<CategoryModel>> getAllCategories();
Future<CategoryModel?> getCategoryById(String id);
Future<void> cacheCategories(List<CategoryModel> categories);
Future<void> clearCategories();
}
class CategoryLocalDataSourceImpl implements CategoryLocalDataSource {
final Box<CategoryModel> box;
CategoryLocalDataSourceImpl(this.box);
@override
Future<List<CategoryModel>> getAllCategories() async {
return box.values.toList();
}
@override
Future<CategoryModel?> getCategoryById(String id) async {
return box.get(id);
}
@override
Future<void> cacheCategories(List<CategoryModel> categories) async {
final categoryMap = {for (var c in categories) c.id: c};
await box.putAll(categoryMap);
}
@override
Future<void> clearCategories() async {
await box.clear();
}
}

View File

@@ -0,0 +1,112 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/category.dart';
import '../../../../core/constants/storage_constants.dart';
part 'category_model.g.dart';
@HiveType(typeId: StorageConstants.categoryTypeId)
class CategoryModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String? description;
@HiveField(3)
final String? iconPath;
@HiveField(4)
final String? color;
@HiveField(5)
final int productCount;
@HiveField(6)
final DateTime createdAt;
CategoryModel({
required this.id,
required this.name,
this.description,
this.iconPath,
this.color,
required this.productCount,
required this.createdAt,
});
/// Convert to domain entity
Category toEntity() {
return Category(
id: id,
name: name,
description: description,
iconPath: iconPath,
color: color,
productCount: productCount,
createdAt: createdAt,
);
}
/// Create from domain entity
factory CategoryModel.fromEntity(Category category) {
return CategoryModel(
id: category.id,
name: category.name,
description: category.description,
iconPath: category.iconPath,
color: category.color,
productCount: category.productCount,
createdAt: category.createdAt,
);
}
/// Create from JSON
factory CategoryModel.fromJson(Map<String, dynamic> json) {
return CategoryModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
iconPath: json['iconPath'] as String?,
color: json['color'] as String?,
productCount: json['productCount'] as int,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'iconPath': iconPath,
'color': color,
'productCount': productCount,
'createdAt': createdAt.toIso8601String(),
};
}
/// Create a copy with updated fields
CategoryModel copyWith({
String? id,
String? name,
String? description,
String? iconPath,
String? color,
int? productCount,
DateTime? createdAt,
}) {
return CategoryModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
iconPath: iconPath ?? this.iconPath,
color: color ?? this.color,
productCount: productCount ?? this.productCount,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
@override
final typeId = 1;
@override
CategoryModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CategoryModel(
id: fields[0] as String,
name: fields[1] as String,
description: fields[2] as String?,
iconPath: fields[3] as String?,
color: fields[4] as String?,
productCount: (fields[5] as num).toInt(),
createdAt: fields[6] as DateTime,
);
}
@override
void write(BinaryWriter writer, CategoryModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.description)
..writeByte(3)
..write(obj.iconPath)
..writeByte(4)
..write(obj.color)
..writeByte(5)
..write(obj.productCount)
..writeByte(6)
..write(obj.createdAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CategoryModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,49 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/category.dart';
import '../../domain/repositories/category_repository.dart';
import '../datasources/category_local_datasource.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class CategoryRepositoryImpl implements CategoryRepository {
final CategoryLocalDataSource localDataSource;
CategoryRepositoryImpl({
required this.localDataSource,
});
@override
Future<Either<Failure, List<Category>>> getAllCategories() async {
try {
final categories = await localDataSource.getAllCategories();
return Right(categories.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, Category>> getCategoryById(String id) async {
try {
final category = await localDataSource.getCategoryById(id);
if (category == null) {
return Left(NotFoundFailure('Category not found'));
}
return Right(category.toEntity());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, List<Category>>> syncCategories() async {
try {
// For now, return cached categories
// In the future, implement remote sync
final categories = await localDataSource.getAllCategories();
return Right(categories.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}

View File

@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
/// Category domain entity
class Category extends Equatable {
final String id;
final String name;
final String? description;
final String? iconPath;
final String? color;
final int productCount;
final DateTime createdAt;
const Category({
required this.id,
required this.name,
this.description,
this.iconPath,
this.color,
required this.productCount,
required this.createdAt,
});
@override
List<Object?> get props => [
id,
name,
description,
iconPath,
color,
productCount,
createdAt,
];
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/category.dart';
/// Category repository interface
abstract class CategoryRepository {
/// Get all categories from cache
Future<Either<Failure, List<Category>>> getAllCategories();
/// Get category by ID
Future<Either<Failure, Category>> getCategoryById(String id);
/// Sync categories from remote
Future<Either<Failure, List<Category>>> syncCategories();
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/category.dart';
import '../repositories/category_repository.dart';
/// Use case to get all categories
class GetAllCategories {
final CategoryRepository repository;
GetAllCategories(this.repository);
Future<Either<Failure, List<Category>>> call() async {
return await repository.getAllCategories();
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/category_grid.dart';
import '../providers/categories_provider.dart';
import '../../../products/presentation/providers/selected_category_provider.dart' as product_providers;
/// Categories page - displays all categories in a grid
class CategoriesPage extends ConsumerWidget {
const CategoriesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Categories'),
actions: [
// Refresh button
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh categories',
onPressed: () {
ref.invalidate(categoriesProvider);
},
),
],
),
body: RefreshIndicator(
onRefresh: () async {
await ref.refresh(categoriesProvider.future);
},
child: categoriesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading categories',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => ref.invalidate(categoriesProvider),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (categories) {
return Column(
children: [
// Categories count
if (categories.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${categories.length} categor${categories.length == 1 ? 'y' : 'ies'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
// Category grid
Expanded(
child: CategoryGrid(
onCategoryTap: (category) {
// Set selected category
ref
.read(product_providers.selectedCategoryProvider.notifier)
.selectCategory(category.id);
// Show snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Filtering products by ${category.name}',
),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: 'View',
onPressed: () {
// Navigate to products tab
// This will be handled by the parent widget
// For now, just show a message
},
),
),
);
},
),
),
],
);
},
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/category.dart';
part 'categories_provider.g.dart';
/// Provider for categories list
@riverpod
class Categories extends _$Categories {
@override
Future<List<Category>> build() async {
// TODO: Implement with repository
return [];
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Fetch categories from repository
return [];
});
}
Future<void> syncCategories() async {
// TODO: Implement sync logic with remote data source
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Sync categories from API
return [];
});
}
}
/// Provider for selected category
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String? build() => null;
void select(String? categoryId) {
state = categoryId;
}
void clear() {
state = null;
}
}

View File

@@ -0,0 +1,119 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'categories_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for categories list
@ProviderFor(Categories)
const categoriesProvider = CategoriesProvider._();
/// Provider for categories list
final class CategoriesProvider
extends $AsyncNotifierProvider<Categories, List<Category>> {
/// Provider for categories list
const CategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'categoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoriesHash();
@$internal
@override
Categories create() => Categories();
}
String _$categoriesHash() => r'aa7afc38a5567b0f42ff05ca23b287baa4780cbe';
/// Provider for categories list
abstract class _$Categories extends $AsyncNotifier<List<Category>> {
FutureOr<List<Category>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Category>>, List<Category>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Category>>, List<Category>>,
AsyncValue<List<Category>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for selected category
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Provider for selected category
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String?> {
/// Provider for selected category
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c';
/// Provider for selected category
abstract class _$SelectedCategory extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/datasources/category_local_datasource.dart';
import '../../../../core/database/hive_database.dart';
import '../../data/models/category_model.dart';
part 'category_datasource_provider.g.dart';
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
@Riverpod(keepAlive: true)
CategoryLocalDataSource categoryLocalDataSource(Ref ref) {
final box = HiveDatabase.instance.getBox<CategoryModel>('categories');
return CategoryLocalDataSourceImpl(box);
}

View File

@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_datasource_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
@ProviderFor(categoryLocalDataSource)
const categoryLocalDataSourceProvider = CategoryLocalDataSourceProvider._();
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
final class CategoryLocalDataSourceProvider
extends
$FunctionalProvider<
CategoryLocalDataSource,
CategoryLocalDataSource,
CategoryLocalDataSource
>
with $Provider<CategoryLocalDataSource> {
/// Provider for category local data source
/// This is kept alive as it's a dependency injection provider
const CategoryLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'categoryLocalDataSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoryLocalDataSourceHash();
@$internal
@override
$ProviderElement<CategoryLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
CategoryLocalDataSource create(Ref ref) {
return categoryLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CategoryLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CategoryLocalDataSource>(value),
);
}
}
String _$categoryLocalDataSourceHash() =>
r'1f8412f2dc76a348873f1da4f76ae4a08991f269';

View File

@@ -0,0 +1,35 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../products/presentation/providers/products_provider.dart';
part 'category_product_count_provider.g.dart';
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
@riverpod
int categoryProductCount(Ref ref, String categoryId) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) => products.where((p) => p.categoryId == categoryId).length,
loading: () => 0,
error: (_, __) => 0,
);
}
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
@riverpod
Map<String, int> allCategoryProductCounts(Ref ref) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) {
// Group products by category and count
final counts = <String, int>{};
for (final product in products) {
counts[product.categoryId] = (counts[product.categoryId] ?? 0) + 1;
}
return counts;
},
loading: () => {},
error: (_, __) => {},
);
}

View File

@@ -0,0 +1,156 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_product_count_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
@ProviderFor(categoryProductCount)
const categoryProductCountProvider = CategoryProductCountFamily._();
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
final class CategoryProductCountProvider
extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
const CategoryProductCountProvider._({
required CategoryProductCountFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'categoryProductCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoryProductCountHash();
@override
String toString() {
return r'categoryProductCountProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
final argument = this.argument as String;
return categoryProductCount(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
@override
bool operator ==(Object other) {
return other is CategoryProductCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$categoryProductCountHash() =>
r'2d51eea21a4d018964d10ee00d0957a2c38d28c6';
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
final class CategoryProductCountFamily extends $Family
with $FunctionalFamilyOverride<int, String> {
const CategoryProductCountFamily._()
: super(
retry: null,
name: r'categoryProductCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider that calculates product count for a specific category
/// Uses family pattern to create a provider for each category ID
CategoryProductCountProvider call(String categoryId) =>
CategoryProductCountProvider._(argument: categoryId, from: this);
@override
String toString() => r'categoryProductCountProvider';
}
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
@ProviderFor(allCategoryProductCounts)
const allCategoryProductCountsProvider = AllCategoryProductCountsProvider._();
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
final class AllCategoryProductCountsProvider
extends
$FunctionalProvider<
Map<String, int>,
Map<String, int>,
Map<String, int>
>
with $Provider<Map<String, int>> {
/// Provider that returns all category product counts as a map
/// Useful for displaying product counts on all category cards at once
const AllCategoryProductCountsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'allCategoryProductCountsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$allCategoryProductCountsHash();
@$internal
@override
$ProviderElement<Map<String, int>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Map<String, int> create(Ref ref) {
return allCategoryProductCounts(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, int> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, int>>(value),
);
}
}
String _$allCategoryProductCountsHash() =>
r'a4ecc281916772ac74327333bd76e7b6463a0992';

View File

@@ -0,0 +1,4 @@
/// Export all category providers
export 'category_datasource_provider.dart';
export 'categories_provider.dart';
export 'category_product_count_provider.dart';

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../domain/entities/category.dart';
/// Category card widget
class CategoryCard extends StatelessWidget {
final Category category;
const CategoryCard({
super.key,
required this.category,
});
@override
Widget build(BuildContext context) {
final color = category.color != null
? Color(int.parse(category.color!.substring(1), radix: 16) + 0xFF000000)
: Theme.of(context).colorScheme.primaryContainer;
return Card(
color: color,
child: InkWell(
onTap: () {
// TODO: Filter products by category
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category,
size: 48,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(height: 8),
Text(
category.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${category.productCount} products',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/categories_provider.dart';
import '../../domain/entities/category.dart';
import 'category_card.dart';
import '../../../../core/widgets/loading_indicator.dart';
import '../../../../core/widgets/error_widget.dart';
import '../../../../core/widgets/empty_state.dart';
/// Category grid widget
class CategoryGrid extends ConsumerWidget {
final void Function(Category)? onCategoryTap;
const CategoryGrid({
super.key,
this.onCategoryTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
return categoriesAsync.when(
loading: () => const LoadingIndicator(message: 'Loading categories...'),
error: (error, stack) => ErrorDisplay(
message: error.toString(),
onRetry: () => ref.refresh(categoriesProvider),
),
data: (categories) {
if (categories.isEmpty) {
return const EmptyState(
message: 'No categories found',
subMessage: 'Categories will appear here once added',
icon: Icons.category_outlined,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Determine grid columns based on width
int crossAxisCount = 2;
if (constraints.maxWidth > 1200) {
crossAxisCount = 4;
} else if (constraints.maxWidth > 800) {
crossAxisCount = 3;
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return RepaintBoundary(
child: GestureDetector(
onTap: () => onCategoryTap?.call(category),
child: CategoryCard(category: category),
),
);
},
);
},
);
},
);
}
}

View File

@@ -0,0 +1,5 @@
// Category Feature Widgets
export 'category_card.dart';
export 'category_grid.dart';
// This file provides a central export point for all category widgets

View File

@@ -0,0 +1,53 @@
import 'package:hive_ce/hive.dart';
import '../models/cart_item_model.dart';
/// Cart local data source using Hive
abstract class CartLocalDataSource {
Future<List<CartItemModel>> getCartItems();
Future<void> addToCart(CartItemModel item);
Future<void> updateQuantity(String productId, int quantity);
Future<void> removeFromCart(String productId);
Future<void> clearCart();
}
class CartLocalDataSourceImpl implements CartLocalDataSource {
final Box<CartItemModel> box;
CartLocalDataSourceImpl(this.box);
@override
Future<List<CartItemModel>> getCartItems() async {
return box.values.toList();
}
@override
Future<void> addToCart(CartItemModel item) async {
await box.put(item.productId, item);
}
@override
Future<void> updateQuantity(String productId, int quantity) async {
final item = box.get(productId);
if (item != null) {
final updated = CartItemModel(
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: quantity,
imageUrl: item.imageUrl,
addedAt: item.addedAt,
);
await box.put(productId, updated);
}
}
@override
Future<void> removeFromCart(String productId) async {
await box.delete(productId);
}
@override
Future<void> clearCart() async {
await box.clear();
}
}

View File

@@ -0,0 +1,83 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/cart_item.dart';
import '../../../../core/constants/storage_constants.dart';
part 'cart_item_model.g.dart';
@HiveType(typeId: StorageConstants.cartItemTypeId)
class CartItemModel extends HiveObject {
@HiveField(0)
final String productId;
@HiveField(1)
final String productName;
@HiveField(2)
final double price;
@HiveField(3)
final int quantity;
@HiveField(4)
final String? imageUrl;
@HiveField(5)
final DateTime addedAt;
CartItemModel({
required this.productId,
required this.productName,
required this.price,
required this.quantity,
this.imageUrl,
required this.addedAt,
});
/// Convert to domain entity
CartItem toEntity() {
return CartItem(
productId: productId,
productName: productName,
price: price,
quantity: quantity,
imageUrl: imageUrl,
addedAt: addedAt,
);
}
/// Create from domain entity
factory CartItemModel.fromEntity(CartItem item) {
return CartItemModel(
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: item.quantity,
imageUrl: item.imageUrl,
addedAt: item.addedAt,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'productId': productId,
'productName': productName,
'price': price,
'quantity': quantity,
'imageUrl': imageUrl,
'addedAt': addedAt.toIso8601String(),
};
}
/// Create from JSON
factory CartItemModel.fromJson(Map<String, dynamic> json) {
return CartItemModel(
productId: json['productId'] as String,
productName: json['productName'] as String,
price: (json['price'] as num).toDouble(),
quantity: json['quantity'] as int,
imageUrl: json['imageUrl'] as String?,
addedAt: DateTime.parse(json['addedAt'] as String),
);
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_item_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
@override
final typeId = 2;
@override
CartItemModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CartItemModel(
productId: fields[0] as String,
productName: fields[1] as String,
price: (fields[2] as num).toDouble(),
quantity: (fields[3] as num).toInt(),
imageUrl: fields[4] as String?,
addedAt: fields[5] as DateTime,
);
}
@override
void write(BinaryWriter writer, CartItemModel obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.productId)
..writeByte(1)
..write(obj.productName)
..writeByte(2)
..write(obj.price)
..writeByte(3)
..write(obj.quantity)
..writeByte(4)
..write(obj.imageUrl)
..writeByte(5)
..write(obj.addedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CartItemModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,123 @@
import 'package:hive_ce/hive.dart';
import 'package:retail/core/constants/storage_constants.dart';
import 'package:retail/features/home/data/models/cart_item_model.dart';
part 'transaction_model.g.dart';
/// Transaction model with Hive CE type adapter
@HiveType(typeId: StorageConstants.transactionTypeId)
class TransactionModel extends HiveObject {
/// Unique transaction identifier
@HiveField(0)
final String id;
/// List of cart items in this transaction
@HiveField(1)
final List<CartItemModel> items;
/// Subtotal amount (before tax and discount)
@HiveField(2)
final double subtotal;
/// Tax amount
@HiveField(3)
final double tax;
/// Discount amount
@HiveField(4)
final double discount;
/// Total amount (subtotal + tax - discount)
@HiveField(5)
final double total;
/// Transaction completion timestamp
@HiveField(6)
final DateTime completedAt;
/// Payment method used (e.g., 'cash', 'card', 'digital')
@HiveField(7)
final String paymentMethod;
TransactionModel({
required this.id,
required this.items,
required this.subtotal,
required this.tax,
required this.discount,
required this.total,
required this.completedAt,
required this.paymentMethod,
});
/// Get total number of items in transaction
int get totalItems => items.fold(0, (sum, item) => sum + item.quantity);
/// Create a copy with updated fields
TransactionModel copyWith({
String? id,
List<CartItemModel>? items,
double? subtotal,
double? tax,
double? discount,
double? total,
DateTime? completedAt,
String? paymentMethod,
}) {
return TransactionModel(
id: id ?? this.id,
items: items ?? this.items,
subtotal: subtotal ?? this.subtotal,
tax: tax ?? this.tax,
discount: discount ?? this.discount,
total: total ?? this.total,
completedAt: completedAt ?? this.completedAt,
paymentMethod: paymentMethod ?? this.paymentMethod,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'items': items.map((item) => item.toJson()).toList(),
'subtotal': subtotal,
'tax': tax,
'discount': discount,
'total': total,
'completedAt': completedAt.toIso8601String(),
'paymentMethod': paymentMethod,
};
}
/// Create from JSON
factory TransactionModel.fromJson(Map<String, dynamic> json) {
return TransactionModel(
id: json['id'] as String,
items: (json['items'] as List)
.map((item) => CartItemModel.fromJson(item as Map<String, dynamic>))
.toList(),
subtotal: (json['subtotal'] as num).toDouble(),
tax: (json['tax'] as num).toDouble(),
discount: (json['discount'] as num).toDouble(),
total: (json['total'] as num).toDouble(),
completedAt: DateTime.parse(json['completedAt'] as String),
paymentMethod: json['paymentMethod'] as String,
);
}
@override
String toString() {
return 'TransactionModel(id: $id, total: $total, items: ${items.length}, method: $paymentMethod)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TransactionModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'transaction_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class TransactionModelAdapter extends TypeAdapter<TransactionModel> {
@override
final typeId = 3;
@override
TransactionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return TransactionModel(
id: fields[0] as String,
items: (fields[1] as List).cast<CartItemModel>(),
subtotal: (fields[2] as num).toDouble(),
tax: (fields[3] as num).toDouble(),
discount: (fields[4] as num).toDouble(),
total: (fields[5] as num).toDouble(),
completedAt: fields[6] as DateTime,
paymentMethod: fields[7] as String,
);
}
@override
void write(BinaryWriter writer, TransactionModel obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.items)
..writeByte(2)
..write(obj.subtotal)
..writeByte(3)
..write(obj.tax)
..writeByte(4)
..write(obj.discount)
..writeByte(5)
..write(obj.total)
..writeByte(6)
..write(obj.completedAt)
..writeByte(7)
..write(obj.paymentMethod);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TransactionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,66 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/cart_item.dart';
import '../../domain/repositories/cart_repository.dart';
import '../datasources/cart_local_datasource.dart';
import '../models/cart_item_model.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class CartRepositoryImpl implements CartRepository {
final CartLocalDataSource localDataSource;
CartRepositoryImpl({
required this.localDataSource,
});
@override
Future<Either<Failure, List<CartItem>>> getCartItems() async {
try {
final items = await localDataSource.getCartItems();
return Right(items.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> addToCart(CartItem item) async {
try {
final model = CartItemModel.fromEntity(item);
await localDataSource.addToCart(model);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> updateQuantity(String productId, int quantity) async {
try {
await localDataSource.updateQuantity(productId, quantity);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> removeFromCart(String productId) async {
try {
await localDataSource.removeFromCart(productId);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> clearCart() async {
try {
await localDataSource.clearCart();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// Cart item domain entity
class CartItem extends Equatable {
final String productId;
final String productName;
final double price;
final int quantity;
final String? imageUrl;
final DateTime addedAt;
const CartItem({
required this.productId,
required this.productName,
required this.price,
required this.quantity,
this.imageUrl,
required this.addedAt,
});
double get total => price * quantity;
CartItem copyWith({
String? productId,
String? productName,
double? price,
int? quantity,
String? imageUrl,
DateTime? addedAt,
}) {
return CartItem(
productId: productId ?? this.productId,
productName: productName ?? this.productName,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
imageUrl: imageUrl ?? this.imageUrl,
addedAt: addedAt ?? this.addedAt,
);
}
@override
List<Object?> get props => [
productId,
productName,
price,
quantity,
imageUrl,
addedAt,
];
}

View File

@@ -0,0 +1,21 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/cart_item.dart';
/// Cart repository interface
abstract class CartRepository {
/// Get all cart items
Future<Either<Failure, List<CartItem>>> getCartItems();
/// Add item to cart
Future<Either<Failure, void>> addToCart(CartItem item);
/// Update cart item quantity
Future<Either<Failure, void>> updateQuantity(String productId, int quantity);
/// Remove item from cart
Future<Either<Failure, void>> removeFromCart(String productId);
/// Clear all cart items
Future<Either<Failure, void>> clearCart();
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/cart_item.dart';
import '../repositories/cart_repository.dart';
/// Use case to add item to cart
class AddToCart {
final CartRepository repository;
AddToCart(this.repository);
Future<Either<Failure, void>> call(CartItem item) async {
return await repository.addToCart(item);
}
}

View File

@@ -0,0 +1,8 @@
import '../entities/cart_item.dart';
/// Use case to calculate cart total
class CalculateTotal {
double call(List<CartItem> items) {
return items.fold(0.0, (sum, item) => sum + item.total);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../repositories/cart_repository.dart';
/// Use case to clear cart
class ClearCart {
final CartRepository repository;
ClearCart(this.repository);
Future<Either<Failure, void>> call() async {
return await repository.clearCart();
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../repositories/cart_repository.dart';
/// Use case to remove item from cart
class RemoveFromCart {
final CartRepository repository;
RemoveFromCart(this.repository);
Future<Either<Failure, void>> call(String productId) async {
return await repository.removeFromCart(productId);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/product_selector.dart';
import '../widgets/cart_summary.dart';
import '../providers/cart_provider.dart';
import '../../domain/entities/cart_item.dart';
/// Home page - POS interface with product selector and cart
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cartAsync = ref.watch(cartProvider);
final isWideScreen = MediaQuery.of(context).size.width > 600;
return Scaffold(
appBar: AppBar(
title: const Text('Point of Sale'),
actions: [
// Cart item count badge
cartAsync.whenOrNull(
data: (items) => items.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: Badge(
label: Text('${items.length}'),
child: const Icon(Icons.shopping_cart),
),
),
)
: null,
) ?? const SizedBox.shrink(),
],
),
body: isWideScreen
? Row(
children: [
// Product selector on left
Expanded(
flex: 3,
child: ProductSelector(
onProductTap: (product) {
_showAddToCartDialog(context, ref, product);
},
),
),
// Divider
const VerticalDivider(width: 1),
// Cart on right
const Expanded(
flex: 2,
child: CartSummary(),
),
],
)
: Column(
children: [
// Product selector on top
Expanded(
flex: 2,
child: ProductSelector(
onProductTap: (product) {
_showAddToCartDialog(context, ref, product);
},
),
),
// Divider
const Divider(height: 1),
// Cart on bottom
const Expanded(
flex: 3,
child: CartSummary(),
),
],
),
);
}
void _showAddToCartDialog(
BuildContext context,
WidgetRef ref,
dynamic product,
) {
int quantity = 1;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Add to Cart'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: quantity > 1
? () => setState(() => quantity--)
: null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'$quantity',
style: Theme.of(context).textTheme.headlineSmall,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: quantity < product.stockQuantity
? () => setState(() => quantity++)
: null,
),
],
),
if (product.stockQuantity < 5)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Only ${product.stockQuantity} in stock',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: () {
// Create cart item from product
final cartItem = CartItem(
productId: product.id,
productName: product.name,
price: product.price,
quantity: quantity,
imageUrl: product.imageUrl,
addedAt: DateTime.now(),
);
// Add to cart
ref.read(cartProvider.notifier).addItem(cartItem);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${product.name} to cart'),
duration: const Duration(seconds: 2),
),
);
},
icon: const Icon(Icons.add_shopping_cart),
label: const Text('Add'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'cart_provider.dart';
part 'cart_item_count_provider.g.dart';
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
@riverpod
int cartItemCount(Ref ref) {
final itemsAsync = ref.watch(cartProvider);
return itemsAsync.when(
data: (items) => items.fold<int>(0, (sum, item) => sum + item.quantity),
loading: () => 0,
error: (_, __) => 0,
);
}
/// Provider that calculates unique items count in cart
@riverpod
int cartUniqueItemCount(Ref ref) {
final itemsAsync = ref.watch(cartProvider);
return itemsAsync.when(
data: (items) => items.length,
loading: () => 0,
error: (_, __) => 0,
);
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_item_count_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
@ProviderFor(cartItemCount)
const cartItemCountProvider = CartItemCountProvider._();
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
final class CartItemCountProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
const CartItemCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartItemCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartItemCountHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return cartItemCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$cartItemCountHash() => r'78fe81648a02fb84477df3be3f08b27caa039203';
/// Provider that calculates unique items count in cart
@ProviderFor(cartUniqueItemCount)
const cartUniqueItemCountProvider = CartUniqueItemCountProvider._();
/// Provider that calculates unique items count in cart
final class CartUniqueItemCountProvider
extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates unique items count in cart
const CartUniqueItemCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartUniqueItemCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartUniqueItemCountHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return cartUniqueItemCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$cartUniqueItemCountHash() =>
r'51eec092c957d0d4819200fd935115db77c7f8d3';

View File

@@ -0,0 +1,54 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/cart_item.dart';
part 'cart_provider.g.dart';
/// Provider for shopping cart
@riverpod
class Cart extends _$Cart {
@override
Future<List<CartItem>> build() async {
// TODO: Implement with repository
return [];
}
Future<void> addItem(CartItem item) async {
// TODO: Implement add to cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return [...currentItems, item];
});
}
Future<void> removeItem(String productId) async {
// TODO: Implement remove from cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return currentItems.where((item) => item.productId != productId).toList();
});
}
Future<void> updateQuantity(String productId, int quantity) async {
// TODO: Implement update quantity
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return currentItems.map((item) {
if (item.productId == productId) {
return item.copyWith(quantity: quantity);
}
return item;
}).toList();
});
}
Future<void> clearCart() async {
// TODO: Implement clear cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return [];
});
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for shopping cart
@ProviderFor(Cart)
const cartProvider = CartProvider._();
/// Provider for shopping cart
final class CartProvider extends $AsyncNotifierProvider<Cart, List<CartItem>> {
/// Provider for shopping cart
const CartProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartHash();
@$internal
@override
Cart create() => Cart();
}
String _$cartHash() => r'0136ac2c2a04412a130184e30c01e33a17b0d4db';
/// Provider for shopping cart
abstract class _$Cart extends $AsyncNotifier<List<CartItem>> {
FutureOr<List<CartItem>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<CartItem>>, List<CartItem>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<CartItem>>, List<CartItem>>,
AsyncValue<List<CartItem>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'cart_provider.dart';
import '../../../settings/presentation/providers/settings_provider.dart';
part 'cart_total_provider.g.dart';
/// Cart totals calculation provider
@riverpod
class CartTotal extends _$CartTotal {
@override
CartTotalData build() {
final itemsAsync = ref.watch(cartProvider);
final settingsAsync = ref.watch(settingsProvider);
final items = itemsAsync.when(
data: (data) => data,
loading: () => <dynamic>[],
error: (_, __) => <dynamic>[],
);
final settings = settingsAsync.when(
data: (data) => data,
loading: () => null,
error: (_, __) => null,
);
// Calculate subtotal
final subtotal = items.fold<double>(
0.0,
(sum, item) => sum + item.lineTotal,
);
// Calculate tax
final taxRate = settings?.taxRate ?? 0.0;
final tax = subtotal * taxRate;
// Calculate total
final total = subtotal + tax;
return CartTotalData(
subtotal: subtotal,
tax: tax,
taxRate: taxRate,
total: total,
itemCount: items.length,
);
}
/// Apply discount amount to total
double applyDiscount(double discountAmount) {
final currentTotal = state.total;
return (currentTotal - discountAmount).clamp(0.0, double.infinity);
}
/// Apply discount percentage to total
double applyDiscountPercentage(double discountPercent) {
final currentTotal = state.total;
final discountAmount = currentTotal * (discountPercent / 100);
return (currentTotal - discountAmount).clamp(0.0, double.infinity);
}
}
/// Cart total data model
class CartTotalData {
final double subtotal;
final double tax;
final double taxRate;
final double total;
final int itemCount;
const CartTotalData({
required this.subtotal,
required this.tax,
required this.taxRate,
required this.total,
required this.itemCount,
});
@override
String toString() {
return 'CartTotalData(subtotal: $subtotal, tax: $tax, total: $total, items: $itemCount)';
}
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_total_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Cart totals calculation provider
@ProviderFor(CartTotal)
const cartTotalProvider = CartTotalProvider._();
/// Cart totals calculation provider
final class CartTotalProvider
extends $NotifierProvider<CartTotal, CartTotalData> {
/// Cart totals calculation provider
const CartTotalProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartTotalProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartTotalHash();
@$internal
@override
CartTotal create() => CartTotal();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CartTotalData value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CartTotalData>(value),
);
}
}
String _$cartTotalHash() => r'044f6d4749eec49f9ef4173fc42d149a3841df21';
/// Cart totals calculation provider
abstract class _$CartTotal extends $Notifier<CartTotalData> {
CartTotalData build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<CartTotalData, CartTotalData>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<CartTotalData, CartTotalData>,
CartTotalData,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,4 @@
/// Export all home/cart providers
export 'cart_provider.dart';
export 'cart_total_provider.dart';
export 'cart_item_count_provider.dart';

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../domain/entities/cart_item.dart';
import '../../../../shared/widgets/price_display.dart';
/// Cart item card widget
class CartItemCard extends StatelessWidget {
final CartItem item;
final VoidCallback? onRemove;
final Function(int)? onQuantityChanged;
const CartItemCard({
super.key,
required this.item,
this.onRemove,
this.onQuantityChanged,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
PriceDisplay(price: item.price),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: item.quantity > 1
? () => onQuantityChanged?.call(item.quantity - 1)
: null,
),
Text(
'${item.quantity}',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => onQuantityChanged?.call(item.quantity + 1),
),
],
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onRemove,
color: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/cart_provider.dart';
import '../providers/cart_total_provider.dart';
import 'cart_item_card.dart';
import '../../../../shared/widgets/price_display.dart';
import '../../../../core/widgets/empty_state.dart';
/// Cart summary widget
class CartSummary extends ConsumerWidget {
const CartSummary({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cartAsync = ref.watch(cartProvider);
final totalData = ref.watch(cartTotalProvider);
return Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Shopping Cart',
style: Theme.of(context).textTheme.titleLarge,
),
if (cartAsync.value?.isNotEmpty ?? false)
TextButton.icon(
onPressed: () {
ref.read(cartProvider.notifier).clearCart();
},
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear'),
),
],
),
),
Expanded(
child: cartAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (items) {
if (items.isEmpty) {
return const EmptyState(
message: 'Cart is empty',
subMessage: 'Add products to get started',
icon: Icons.shopping_cart_outlined,
);
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return CartItemCard(
item: item,
onRemove: () {
ref.read(cartProvider.notifier).removeItem(item.productId);
},
onQuantityChanged: (quantity) {
ref.read(cartProvider.notifier).updateQuantity(
item.productId,
quantity,
);
},
);
},
);
},
),
),
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total:',
style: Theme.of(context).textTheme.titleLarge,
),
PriceDisplay(
price: totalData.total,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: (cartAsync.value?.isNotEmpty ?? false)
? () {
// TODO: Implement checkout
}
: null,
icon: const Icon(Icons.payment),
label: const Text('Checkout'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../products/presentation/providers/products_provider.dart';
import '../../../products/presentation/widgets/product_card.dart';
import '../../../products/domain/entities/product.dart';
import '../../../../core/widgets/loading_indicator.dart';
import '../../../../core/widgets/error_widget.dart';
import '../../../../core/widgets/empty_state.dart';
/// Product selector widget for POS
class ProductSelector extends ConsumerWidget {
final void Function(Product)? onProductTap;
const ProductSelector({
super.key,
this.onProductTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select Products',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: productsAsync.when(
loading: () => const LoadingIndicator(
message: 'Loading products...',
),
error: (error, stack) => ErrorDisplay(
message: error.toString(),
onRetry: () => ref.refresh(productsProvider),
),
data: (products) {
if (products.isEmpty) {
return const EmptyState(
message: 'No products available',
subMessage: 'Add products to start selling',
icon: Icons.inventory_2_outlined,
);
}
// Filter only available products for POS
final availableProducts =
products.where((p) => p.isAvailable).toList();
if (availableProducts.isEmpty) {
return const EmptyState(
message: 'No products available',
subMessage: 'All products are currently unavailable',
icon: Icons.inventory_2_outlined,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Determine grid columns based on width
int crossAxisCount = 2;
if (constraints.maxWidth > 800) {
crossAxisCount = 4;
} else if (constraints.maxWidth > 600) {
crossAxisCount = 3;
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: availableProducts.length,
itemBuilder: (context, index) {
final product = availableProducts[index];
return GestureDetector(
onTap: () => onProductTap?.call(product),
child: ProductCard(product: product),
);
},
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,5 @@
// Home/Cart Feature Widgets
export 'cart_item_card.dart';
export 'cart_summary.dart';
// This file provides a central export point for all home/cart widgets

View File

@@ -0,0 +1,37 @@
import 'package:hive_ce/hive.dart';
import '../models/product_model.dart';
/// Product local data source using Hive
abstract class ProductLocalDataSource {
Future<List<ProductModel>> getAllProducts();
Future<ProductModel?> getProductById(String id);
Future<void> cacheProducts(List<ProductModel> products);
Future<void> clearProducts();
}
class ProductLocalDataSourceImpl implements ProductLocalDataSource {
final Box<ProductModel> box;
ProductLocalDataSourceImpl(this.box);
@override
Future<List<ProductModel>> getAllProducts() async {
return box.values.toList();
}
@override
Future<ProductModel?> getProductById(String id) async {
return box.get(id);
}
@override
Future<void> cacheProducts(List<ProductModel> products) async {
final productMap = {for (var p in products) p.id: p};
await box.putAll(productMap);
}
@override
Future<void> clearProducts() async {
await box.clear();
}
}

View File

@@ -0,0 +1,39 @@
import '../models/product_model.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/constants/api_constants.dart';
/// Product remote data source using API
abstract class ProductRemoteDataSource {
Future<List<ProductModel>> getAllProducts();
Future<ProductModel> getProductById(String id);
Future<List<ProductModel>> searchProducts(String query);
}
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final DioClient client;
ProductRemoteDataSourceImpl(this.client);
@override
Future<List<ProductModel>> getAllProducts() async {
final response = await client.get(ApiConstants.products);
final List<dynamic> data = response.data['products'] ?? [];
return data.map((json) => ProductModel.fromJson(json)).toList();
}
@override
Future<ProductModel> getProductById(String id) async {
final response = await client.get(ApiConstants.productById(id));
return ProductModel.fromJson(response.data);
}
@override
Future<List<ProductModel>> searchProducts(String query) async {
final response = await client.get(
ApiConstants.searchProducts,
queryParameters: {'q': query},
);
final List<dynamic> data = response.data['products'] ?? [];
return data.map((json) => ProductModel.fromJson(json)).toList();
}
}

View File

@@ -0,0 +1,115 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/product.dart';
import '../../../../core/constants/storage_constants.dart';
part 'product_model.g.dart';
@HiveType(typeId: StorageConstants.productTypeId)
class ProductModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String description;
@HiveField(3)
final double price;
@HiveField(4)
final String? imageUrl;
@HiveField(5)
final String categoryId;
@HiveField(6)
final int stockQuantity;
@HiveField(7)
final bool isAvailable;
@HiveField(8)
final DateTime createdAt;
@HiveField(9)
final DateTime updatedAt;
ProductModel({
required this.id,
required this.name,
required this.description,
required this.price,
this.imageUrl,
required this.categoryId,
required this.stockQuantity,
required this.isAvailable,
required this.createdAt,
required this.updatedAt,
});
/// Convert to domain entity
Product toEntity() {
return Product(
id: id,
name: name,
description: description,
price: price,
imageUrl: imageUrl,
categoryId: categoryId,
stockQuantity: stockQuantity,
isAvailable: isAvailable,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
/// Create from domain entity
factory ProductModel.fromEntity(Product product) {
return ProductModel(
id: product.id,
name: product.name,
description: product.description,
price: product.price,
imageUrl: product.imageUrl,
categoryId: product.categoryId,
stockQuantity: product.stockQuantity,
isAvailable: product.isAvailable,
createdAt: product.createdAt,
updatedAt: product.updatedAt,
);
}
/// Create from JSON
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['imageUrl'] as String?,
categoryId: json['categoryId'] as String,
stockQuantity: json['stockQuantity'] as int,
isAvailable: json['isAvailable'] as bool,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'imageUrl': imageUrl,
'categoryId': categoryId,
'stockQuantity': stockQuantity,
'isAvailable': isAvailable,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProductModelAdapter extends TypeAdapter<ProductModel> {
@override
final typeId = 0;
@override
ProductModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProductModel(
id: fields[0] as String,
name: fields[1] as String,
description: fields[2] as String,
price: (fields[3] as num).toDouble(),
imageUrl: fields[4] as String?,
categoryId: fields[5] as String,
stockQuantity: (fields[6] as num).toInt(),
isAvailable: fields[7] as bool,
createdAt: fields[8] as DateTime,
updatedAt: fields[9] as DateTime,
);
}
@override
void write(BinaryWriter writer, ProductModel obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.description)
..writeByte(3)
..write(obj.price)
..writeByte(4)
..write(obj.imageUrl)
..writeByte(5)
..write(obj.categoryId)
..writeByte(6)
..write(obj.stockQuantity)
..writeByte(7)
..write(obj.isAvailable)
..writeByte(8)
..write(obj.createdAt)
..writeByte(9)
..write(obj.updatedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProductModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,78 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../datasources/product_local_datasource.dart';
import '../datasources/product_remote_datasource.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class ProductRepositoryImpl implements ProductRepository {
final ProductLocalDataSource localDataSource;
final ProductRemoteDataSource remoteDataSource;
ProductRepositoryImpl({
required this.localDataSource,
required this.remoteDataSource,
});
@override
Future<Either<Failure, List<Product>>> getAllProducts() async {
try {
final products = await localDataSource.getAllProducts();
return Right(products.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async {
try {
final allProducts = await localDataSource.getAllProducts();
final filtered = allProducts.where((p) => p.categoryId == categoryId).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, List<Product>>> searchProducts(String query) async {
try {
final allProducts = await localDataSource.getAllProducts();
final filtered = allProducts.where((p) =>
p.name.toLowerCase().contains(query.toLowerCase()) ||
p.description.toLowerCase().contains(query.toLowerCase())
).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, Product>> getProductById(String id) async {
try {
final product = await localDataSource.getProductById(id);
if (product == null) {
return Left(NotFoundFailure('Product not found'));
}
return Right(product.toEntity());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, List<Product>>> syncProducts() async {
try {
final products = await remoteDataSource.getAllProducts();
await localDataSource.cacheProducts(products);
return Right(products.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
}
}
}

View File

@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// Product domain entity
class Product extends Equatable {
final String id;
final String name;
final String description;
final double price;
final String? imageUrl;
final String categoryId;
final int stockQuantity;
final bool isAvailable;
final DateTime createdAt;
final DateTime updatedAt;
const Product({
required this.id,
required this.name,
required this.description,
required this.price,
this.imageUrl,
required this.categoryId,
required this.stockQuantity,
required this.isAvailable,
required this.createdAt,
required this.updatedAt,
});
@override
List<Object?> get props => [
id,
name,
description,
price,
imageUrl,
categoryId,
stockQuantity,
isAvailable,
createdAt,
updatedAt,
];
}

View File

@@ -0,0 +1,21 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product.dart';
/// Product repository interface
abstract class ProductRepository {
/// Get all products from cache
Future<Either<Failure, List<Product>>> getAllProducts();
/// Get products by category
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId);
/// Search products
Future<Either<Failure, List<Product>>> searchProducts(String query);
/// Get product by ID
Future<Either<Failure, Product>> getProductById(String id);
/// Sync products from remote
Future<Either<Failure, List<Product>>> syncProducts();
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
/// Use case to get all products
class GetAllProducts {
final ProductRepository repository;
GetAllProducts(this.repository);
Future<Either<Failure, List<Product>>> call() async {
return await repository.getAllProducts();
}
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
/// Use case to search products
class SearchProducts {
final ProductRepository repository;
SearchProducts(this.repository);
Future<Either<Failure, List<Product>>> call(String query) async {
return await repository.searchProducts(query);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/product_grid.dart';
import '../widgets/product_search_bar.dart';
import '../providers/products_provider.dart';
import '../providers/selected_category_provider.dart' as product_providers;
import '../providers/filtered_products_provider.dart';
import '../../domain/entities/product.dart';
import '../../../categories/presentation/providers/categories_provider.dart';
/// Products page - displays all products in a grid
class ProductsPage extends ConsumerStatefulWidget {
const ProductsPage({super.key});
@override
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
ProductSortOption _sortOption = ProductSortOption.nameAsc;
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(categoriesProvider);
final selectedCategory = ref.watch(product_providers.selectedCategoryProvider);
final productsAsync = ref.watch(productsProvider);
// Get filtered products from the provider
final filteredProducts = productsAsync.when(
data: (products) => products,
loading: () => <Product>[],
error: (_, __) => <Product>[],
);
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
actions: [
// Sort button
PopupMenuButton<ProductSortOption>(
icon: const Icon(Icons.sort),
tooltip: 'Sort products',
onSelected: (option) {
setState(() {
_sortOption = option;
});
},
itemBuilder: (context) => [
const PopupMenuItem(
value: ProductSortOption.nameAsc,
child: Row(
children: [
Icon(Icons.sort_by_alpha),
SizedBox(width: 8),
Text('Name (A-Z)'),
],
),
),
const PopupMenuItem(
value: ProductSortOption.nameDesc,
child: Row(
children: [
Icon(Icons.sort_by_alpha),
SizedBox(width: 8),
Text('Name (Z-A)'),
],
),
),
const PopupMenuItem(
value: ProductSortOption.priceAsc,
child: Row(
children: [
Icon(Icons.attach_money),
SizedBox(width: 8),
Text('Price (Low to High)'),
],
),
),
const PopupMenuItem(
value: ProductSortOption.priceDesc,
child: Row(
children: [
Icon(Icons.attach_money),
SizedBox(width: 8),
Text('Price (High to Low)'),
],
),
),
const PopupMenuItem(
value: ProductSortOption.newest,
child: Row(
children: [
Icon(Icons.access_time),
SizedBox(width: 8),
Text('Newest First'),
],
),
),
const PopupMenuItem(
value: ProductSortOption.oldest,
child: Row(
children: [
Icon(Icons.access_time),
SizedBox(width: 8),
Text('Oldest First'),
],
),
),
],
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(120),
child: Column(
children: [
// Search bar
const Padding(
padding: EdgeInsets.all(8.0),
child: ProductSearchBar(),
),
// Category filter chips
categoriesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (categories) {
if (categories.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
// All categories chip
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: const Text('All'),
selected: selectedCategory == null,
onSelected: (_) {
ref
.read(product_providers.selectedCategoryProvider.notifier)
.clearSelection();
},
),
),
// Category chips
...categories.map(
(category) => Padding(
padding: const EdgeInsets.only(right: 8.0),
child: FilterChip(
label: Text(category.name),
selected: selectedCategory == category.id,
onSelected: (_) {
ref
.read(product_providers.selectedCategoryProvider.notifier)
.selectCategory(category.id);
},
),
),
),
],
),
);
},
),
],
),
),
),
body: RefreshIndicator(
onRefresh: () async {
await ref.refresh(productsProvider.future);
await ref.refresh(categoriesProvider.future);
},
child: Column(
children: [
// Results count
if (filteredProducts.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${filteredProducts.length} product${filteredProducts.length == 1 ? '' : 's'} found',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
// Product grid
Expanded(
child: ProductGrid(
sortOption: _sortOption,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/product.dart';
import 'products_provider.dart';
import 'search_query_provider.dart' as search_providers;
import 'selected_category_provider.dart';
part 'filtered_products_provider.g.dart';
/// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results
@riverpod
class FilteredProducts extends _$FilteredProducts {
@override
List<Product> build() {
// Watch all products
final productsAsync = ref.watch(productsProvider);
final products = productsAsync.when(
data: (data) => data,
loading: () => <Product>[],
error: (_, __) => <Product>[],
);
// Watch search query
final searchQuery = ref.watch(search_providers.searchQueryProvider);
// Watch selected category
final selectedCategory = ref.watch(selectedCategoryProvider);
// Apply filters
return _applyFilters(products, searchQuery, selectedCategory);
}
/// Apply search and category filters to products
List<Product> _applyFilters(
List<Product> products,
String searchQuery,
String? categoryId,
) {
var filtered = products;
// Filter by category if selected
if (categoryId != null) {
filtered = filtered.where((p) => p.categoryId == categoryId).toList();
}
// Filter by search query if present
if (searchQuery.isNotEmpty) {
final lowerQuery = searchQuery.toLowerCase();
filtered = filtered.where((p) {
return p.name.toLowerCase().contains(lowerQuery) ||
p.description.toLowerCase().contains(lowerQuery);
}).toList();
}
return filtered;
}
/// Get available products count
int get availableCount => state.where((p) => p.isAvailable).length;
/// Get out of stock products count
int get outOfStockCount => state.where((p) => !p.isAvailable).length;
}
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
@riverpod
class SortedProducts extends _$SortedProducts {
@override
List<Product> build(ProductSortOption sortOption) {
final filteredProducts = ref.watch(filteredProductsProvider);
return _sortProducts(filteredProducts, sortOption);
}
List<Product> _sortProducts(List<Product> products, ProductSortOption option) {
final sorted = List<Product>.from(products);
switch (option) {
case ProductSortOption.nameAsc:
sorted.sort((a, b) => a.name.compareTo(b.name));
break;
case ProductSortOption.nameDesc:
sorted.sort((a, b) => b.name.compareTo(a.name));
break;
case ProductSortOption.priceAsc:
sorted.sort((a, b) => a.price.compareTo(b.price));
break;
case ProductSortOption.priceDesc:
sorted.sort((a, b) => b.price.compareTo(a.price));
break;
case ProductSortOption.newest:
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt));
break;
case ProductSortOption.oldest:
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt));
break;
}
return sorted;
}
}
/// Product sort options
enum ProductSortOption {
nameAsc,
nameDesc,
priceAsc,
priceDesc,
newest,
oldest,
}

View File

@@ -0,0 +1,186 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'filtered_products_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results
@ProviderFor(FilteredProducts)
const filteredProductsProvider = FilteredProductsProvider._();
/// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results
final class FilteredProductsProvider
extends $NotifierProvider<FilteredProducts, List<Product>> {
/// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results
const FilteredProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'filteredProductsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredProductsHash();
@$internal
@override
FilteredProducts create() => FilteredProducts();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<Product> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<Product>>(value),
);
}
}
String _$filteredProductsHash() => r'04d66ed1cb868008cf3e6aba6571f7928a48e814';
/// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results
abstract class _$FilteredProducts extends $Notifier<List<Product>> {
List<Product> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<List<Product>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<Product>, List<Product>>,
List<Product>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
@ProviderFor(SortedProducts)
const sortedProductsProvider = SortedProductsFamily._();
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
final class SortedProductsProvider
extends $NotifierProvider<SortedProducts, List<Product>> {
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
const SortedProductsProvider._({
required SortedProductsFamily super.from,
required ProductSortOption super.argument,
}) : super(
retry: null,
name: r'sortedProductsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sortedProductsHash();
@override
String toString() {
return r'sortedProductsProvider'
''
'($argument)';
}
@$internal
@override
SortedProducts create() => SortedProducts();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<Product> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<Product>>(value),
);
}
@override
bool operator ==(Object other) {
return other is SortedProductsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$sortedProductsHash() => r'653f1e9af8c188631dadbfe9ed7d944c6876d2d3';
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
final class SortedProductsFamily extends $Family
with
$ClassFamilyOverride<
SortedProducts,
List<Product>,
List<Product>,
List<Product>,
ProductSortOption
> {
const SortedProductsFamily._()
: super(
retry: null,
name: r'sortedProductsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
SortedProductsProvider call(ProductSortOption sortOption) =>
SortedProductsProvider._(argument: sortOption, from: this);
@override
String toString() => r'sortedProductsProvider';
}
/// Provider for sorted products
/// Adds sorting capability on top of filtered products
abstract class _$SortedProducts extends $Notifier<List<Product>> {
late final _$args = ref.$arg as ProductSortOption;
ProductSortOption get sortOption => _$args;
List<Product> build(ProductSortOption sortOption);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref = this.ref as $Ref<List<Product>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<Product>, List<Product>>,
List<Product>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/product.dart';
part 'products_provider.g.dart';
/// Provider for products list
@riverpod
class Products extends _$Products {
@override
Future<List<Product>> build() async {
// TODO: Implement with repository
return [];
}
Future<void> refresh() async {
// TODO: Implement refresh logic
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Fetch products from repository
return [];
});
}
Future<void> syncProducts() async {
// TODO: Implement sync logic with remote data source
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
// Sync products from API
return [];
});
}
}
/// Provider for search query
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String build() => '';
void setQuery(String query) {
state = query;
}
}
/// Provider for filtered products
@riverpod
List<Product> filteredProducts(Ref ref) {
final products = ref.watch(productsProvider).value ?? [];
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return products;
return products.where((p) =>
p.name.toLowerCase().contains(query.toLowerCase()) ||
p.description.toLowerCase().contains(query.toLowerCase())
).toList();
}

View File

@@ -0,0 +1,164 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'products_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for products list
@ProviderFor(Products)
const productsProvider = ProductsProvider._();
/// Provider for products list
final class ProductsProvider
extends $AsyncNotifierProvider<Products, List<Product>> {
/// Provider for products list
const ProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'productsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$productsHash();
@$internal
@override
Products create() => Products();
}
String _$productsHash() => r'9e1d3aaa1d9cf0b4ff03fdfaf4512a7a15336d51';
/// Provider for products list
abstract class _$Products extends $AsyncNotifier<List<Product>> {
FutureOr<List<Product>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
AsyncValue<List<Product>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for search query
@ProviderFor(SearchQuery)
const searchQueryProvider = SearchQueryProvider._();
/// Provider for search query
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Provider for search query
const SearchQueryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchQueryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchQueryHash();
@$internal
@override
SearchQuery create() => SearchQuery();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$searchQueryHash() => r'2c146927785523a0ddf51b23b777a9be4afdc092';
/// Provider for search query
abstract class _$SearchQuery extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String, String>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String, String>,
String,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for filtered products
@ProviderFor(filteredProducts)
const filteredProductsProvider = FilteredProductsProvider._();
/// Provider for filtered products
final class FilteredProductsProvider
extends $FunctionalProvider<List<Product>, List<Product>, List<Product>>
with $Provider<List<Product>> {
/// Provider for filtered products
const FilteredProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'filteredProductsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredProductsHash();
@$internal
@override
$ProviderElement<List<Product>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
List<Product> create(Ref ref) {
return filteredProducts(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<Product> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<Product>>(value),
);
}
}
String _$filteredProductsHash() => r'e4e0c549c454576fc599713a5237435a8dd4b277';

View File

@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_query_provider.g.dart';
/// Search query state provider
/// Manages the current search query string for product filtering
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String build() {
// Initialize with empty search query
return '';
}
/// Update search query
void setQuery(String query) {
state = query.trim();
}
/// Clear search query
void clear() {
state = '';
}
/// Check if search is active
bool get isSearching => state.isNotEmpty;
}

View File

@@ -0,0 +1,71 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_query_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Search query state provider
/// Manages the current search query string for product filtering
@ProviderFor(SearchQuery)
const searchQueryProvider = SearchQueryProvider._();
/// Search query state provider
/// Manages the current search query string for product filtering
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Search query state provider
/// Manages the current search query string for product filtering
const SearchQueryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchQueryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchQueryHash();
@$internal
@override
SearchQuery create() => SearchQuery();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$searchQueryHash() => r'62191c640ca9424065338a26c1af5c4695a46ef5';
/// Search query state provider
/// Manages the current search query string for product filtering
abstract class _$SearchQuery extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String, String>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String, String>,
String,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'selected_category_provider.g.dart';
/// Selected category state provider
/// Manages the currently selected category for product filtering
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String? build() {
// Initialize with no category selected (show all products)
return null;
}
/// Select a category
void selectCategory(String? categoryId) {
state = categoryId;
}
/// Clear category selection (show all products)
void clearSelection() {
state = null;
}
/// Check if a category is selected
bool get hasSelection => state != null;
/// Check if specific category is selected
bool isSelected(String categoryId) => state == categoryId;
}

View File

@@ -0,0 +1,72 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'selected_category_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Selected category state provider
/// Manages the currently selected category for product filtering
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Selected category state provider
/// Manages the currently selected category for product filtering
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String?> {
/// Selected category state provider
/// Manages the currently selected category for product filtering
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryHash() => r'73e33604e69d2e9f9127f21e6784c5fe8ddf4869';
/// Selected category state provider
/// Manages the currently selected category for product filtering
abstract class _$SelectedCategory extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../domain/entities/product.dart';
import '../../../../shared/widgets/price_display.dart';
/// Product card widget
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({
super.key,
required this.product,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// TODO: Navigate to product details or add to cart
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: product.imageUrl != null
? CachedNetworkImage(
imageUrl: product.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(
Icons.image_not_supported,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(
Icons.inventory_2_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
PriceDisplay(price: product.price),
if (product.stockQuantity < 5)
Text(
'Low stock: ${product.stockQuantity}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/filtered_products_provider.dart';
import 'product_card.dart';
import '../../../../core/widgets/empty_state.dart';
/// Product grid widget
class ProductGrid extends ConsumerWidget {
final ProductSortOption sortOption;
const ProductGrid({
super.key,
this.sortOption = ProductSortOption.nameAsc,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filteredProducts = ref.watch(filteredProductsProvider);
// Apply sorting
final sortedProducts = _sortProducts(filteredProducts, sortOption);
if (sortedProducts.isEmpty) {
return const EmptyState(
message: 'No products found',
subMessage: 'Try adjusting your filters',
icon: Icons.inventory_2_outlined,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Determine grid columns based on width
int crossAxisCount = 2;
if (constraints.maxWidth > 1200) {
crossAxisCount = 4;
} else if (constraints.maxWidth > 800) {
crossAxisCount = 3;
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: sortedProducts.length,
itemBuilder: (context, index) {
return RepaintBoundary(
child: ProductCard(product: sortedProducts[index]),
);
},
);
},
);
}
List<dynamic> _sortProducts(List<dynamic> products, ProductSortOption option) {
final sorted = List.from(products);
switch (option) {
case ProductSortOption.nameAsc:
sorted.sort((a, b) => a.name.compareTo(b.name));
break;
case ProductSortOption.nameDesc:
sorted.sort((a, b) => b.name.compareTo(a.name));
break;
case ProductSortOption.priceAsc:
sorted.sort((a, b) => a.price.compareTo(b.price));
break;
case ProductSortOption.priceDesc:
sorted.sort((a, b) => b.price.compareTo(a.price));
break;
case ProductSortOption.newest:
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt));
break;
case ProductSortOption.oldest:
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt));
break;
}
return sorted;
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/products_provider.dart';
/// Product search bar widget
class ProductSearchBar extends ConsumerStatefulWidget {
const ProductSearchBar({super.key});
@override
ConsumerState<ProductSearchBar> createState() => _ProductSearchBarState();
}
class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).setQuery('');
},
)
: null,
),
onChanged: (value) {
ref.read(searchQueryProvider.notifier).setQuery(value);
},
);
}
}

View File

@@ -0,0 +1,6 @@
// Product Feature Widgets
export 'product_card.dart';
export 'product_grid.dart';
export 'product_search_bar.dart';
// This file provides a central export point for all product widgets

View File

@@ -0,0 +1,41 @@
import 'package:hive_ce/hive.dart';
import '../models/app_settings_model.dart';
import '../../../../core/constants/storage_constants.dart';
import '../../../../core/constants/app_constants.dart';
/// Settings local data source using Hive
abstract class SettingsLocalDataSource {
Future<AppSettingsModel> getSettings();
Future<void> updateSettings(AppSettingsModel settings);
}
class SettingsLocalDataSourceImpl implements SettingsLocalDataSource {
final Box<AppSettingsModel> box;
SettingsLocalDataSourceImpl(this.box);
@override
Future<AppSettingsModel> getSettings() async {
var settings = box.get(StorageConstants.settingsKey);
// Return default settings if not found
if (settings == null) {
settings = AppSettingsModel(
themeModeString: 'system',
language: AppConstants.defaultLanguage,
currency: AppConstants.defaultCurrency,
taxRate: AppConstants.defaultTaxRate,
storeName: AppConstants.appName,
enableSync: true,
);
await box.put(StorageConstants.settingsKey, settings);
}
return settings;
}
@override
Future<void> updateSettings(AppSettingsModel settings) async {
await box.put(StorageConstants.settingsKey, settings);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:hive_ce/hive.dart';
import '../../domain/entities/app_settings.dart';
import '../../../../core/constants/storage_constants.dart';
import '../../../../core/constants/app_constants.dart';
part 'app_settings_model.g.dart';
@HiveType(typeId: StorageConstants.appSettingsTypeId)
class AppSettingsModel extends HiveObject {
@HiveField(0)
final String themeModeString;
@HiveField(1)
final String language;
@HiveField(2)
final String currency;
@HiveField(3)
final double taxRate;
@HiveField(4)
final String storeName;
@HiveField(5)
final bool enableSync;
@HiveField(6)
final DateTime? lastSyncAt;
AppSettingsModel({
required this.themeModeString,
required this.language,
required this.currency,
required this.taxRate,
required this.storeName,
required this.enableSync,
this.lastSyncAt,
});
/// Convert to domain entity
AppSettings toEntity() {
ThemeMode themeMode;
switch (themeModeString) {
case 'light':
themeMode = ThemeMode.light;
break;
case 'dark':
themeMode = ThemeMode.dark;
break;
default:
themeMode = ThemeMode.system;
}
return AppSettings(
themeMode: themeMode,
language: language,
currency: currency,
taxRate: taxRate,
storeName: storeName,
enableSync: enableSync,
lastSyncAt: lastSyncAt,
);
}
/// Create from domain entity
factory AppSettingsModel.fromEntity(AppSettings settings) {
String themeModeString;
switch (settings.themeMode) {
case ThemeMode.light:
themeModeString = 'light';
break;
case ThemeMode.dark:
themeModeString = 'dark';
break;
default:
themeModeString = 'system';
}
return AppSettingsModel(
themeModeString: themeModeString,
language: settings.language,
currency: settings.currency,
taxRate: settings.taxRate,
storeName: settings.storeName,
enableSync: settings.enableSync,
lastSyncAt: settings.lastSyncAt,
);
}
/// Create default settings
factory AppSettingsModel.defaultSettings() {
return AppSettingsModel(
themeModeString: 'system',
language: AppConstants.defaultLanguage,
currency: AppConstants.defaultCurrency,
taxRate: AppConstants.defaultTaxRate,
storeName: AppConstants.appName,
enableSync: true,
lastSyncAt: null,
);
}
/// Create from ThemeMode
factory AppSettingsModel.fromThemeMode(ThemeMode mode) {
String themeModeString;
switch (mode) {
case ThemeMode.light:
themeModeString = 'light';
break;
case ThemeMode.dark:
themeModeString = 'dark';
break;
default:
themeModeString = 'system';
}
return AppSettingsModel(
themeModeString: themeModeString,
language: AppConstants.defaultLanguage,
currency: AppConstants.defaultCurrency,
taxRate: AppConstants.defaultTaxRate,
storeName: AppConstants.appName,
enableSync: true,
lastSyncAt: null,
);
}
/// Get ThemeMode from string
ThemeMode get themeMode {
switch (themeModeString) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
/// Create a copy with updated fields
AppSettingsModel copyWith({
String? themeModeString,
String? language,
String? currency,
double? taxRate,
String? storeName,
bool? enableSync,
DateTime? lastSyncAt,
}) {
return AppSettingsModel(
themeModeString: themeModeString ?? this.themeModeString,
language: language ?? this.language,
currency: currency ?? this.currency,
taxRate: taxRate ?? this.taxRate,
storeName: storeName ?? this.storeName,
enableSync: enableSync ?? this.enableSync,
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
);
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppSettingsModelAdapter extends TypeAdapter<AppSettingsModel> {
@override
final typeId = 4;
@override
AppSettingsModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AppSettingsModel(
themeModeString: fields[0] as String,
language: fields[1] as String,
currency: fields[2] as String,
taxRate: (fields[3] as num).toDouble(),
storeName: fields[4] as String,
enableSync: fields[5] as bool,
lastSyncAt: fields[6] as DateTime?,
);
}
@override
void write(BinaryWriter writer, AppSettingsModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.themeModeString)
..writeByte(1)
..write(obj.language)
..writeByte(2)
..write(obj.currency)
..writeByte(3)
..write(obj.taxRate)
..writeByte(4)
..write(obj.storeName)
..writeByte(5)
..write(obj.enableSync)
..writeByte(6)
..write(obj.lastSyncAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppSettingsModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,36 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/app_settings.dart';
import '../../domain/repositories/settings_repository.dart';
import '../datasources/settings_local_datasource.dart';
import '../models/app_settings_model.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class SettingsRepositoryImpl implements SettingsRepository {
final SettingsLocalDataSource localDataSource;
SettingsRepositoryImpl({
required this.localDataSource,
});
@override
Future<Either<Failure, AppSettings>> getSettings() async {
try {
final settings = await localDataSource.getSettings();
return Right(settings.toEntity());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> updateSettings(AppSettings settings) async {
try {
final model = AppSettingsModel.fromEntity(settings);
await localDataSource.updateSettings(model);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}

View File

@@ -0,0 +1,68 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import '../../../../core/constants/app_constants.dart';
/// App settings domain entity
class AppSettings extends Equatable {
final ThemeMode themeMode;
final String language;
final String currency;
final double taxRate;
final String storeName;
final bool enableSync;
final DateTime? lastSyncAt;
const AppSettings({
required this.themeMode,
required this.language,
required this.currency,
required this.taxRate,
required this.storeName,
required this.enableSync,
this.lastSyncAt,
});
AppSettings copyWith({
ThemeMode? themeMode,
String? language,
String? currency,
double? taxRate,
String? storeName,
bool? enableSync,
DateTime? lastSyncAt,
}) {
return AppSettings(
themeMode: themeMode ?? this.themeMode,
language: language ?? this.language,
currency: currency ?? this.currency,
taxRate: taxRate ?? this.taxRate,
storeName: storeName ?? this.storeName,
enableSync: enableSync ?? this.enableSync,
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
);
}
/// Create default settings
factory AppSettings.defaultSettings() {
return const AppSettings(
themeMode: ThemeMode.system,
language: AppConstants.defaultLanguage,
currency: AppConstants.defaultCurrency,
taxRate: AppConstants.defaultTaxRate,
storeName: AppConstants.appName,
enableSync: true,
lastSyncAt: null,
);
}
@override
List<Object?> get props => [
themeMode,
language,
currency,
taxRate,
storeName,
enableSync,
lastSyncAt,
];
}

View File

@@ -0,0 +1,12 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/app_settings.dart';
/// Settings repository interface
abstract class SettingsRepository {
/// Get app settings
Future<Either<Failure, AppSettings>> getSettings();
/// Update app settings
Future<Either<Failure, void>> updateSettings(AppSettings settings);
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/app_settings.dart';
import '../repositories/settings_repository.dart';
/// Use case to get app settings
class GetSettings {
final SettingsRepository repository;
GetSettings(this.repository);
Future<Either<Failure, AppSettings>> call() async {
return await repository.getSettings();
}
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/app_settings.dart';
import '../repositories/settings_repository.dart';
/// Use case to update app settings
class UpdateSettings {
final SettingsRepository repository;
UpdateSettings(this.repository);
Future<Either<Failure, void>> call(AppSettings settings) async {
return await repository.updateSettings(settings);
}
}

View File

@@ -0,0 +1,482 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/settings_provider.dart';
import '../../../../core/constants/app_constants.dart';
/// Settings page
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(settingsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
),
body: settingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(settingsProvider),
child: const Text('Retry'),
),
],
),
),
data: (settings) {
return ListView(
children: [
// Appearance Section
_buildSectionHeader(context, 'Appearance'),
ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('Theme'),
subtitle: Text(_getThemeModeName(settings.themeMode)),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showThemeDialog(context, ref, settings.themeMode);
},
),
const Divider(),
// Localization Section
_buildSectionHeader(context, 'Localization'),
ListTile(
leading: const Icon(Icons.language),
title: const Text('Language'),
subtitle: Text(_getLanguageName(settings.language)),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showLanguageDialog(context, ref, settings.language);
},
),
ListTile(
leading: const Icon(Icons.attach_money),
title: const Text('Currency'),
subtitle: Text(settings.currency),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showCurrencyDialog(context, ref, settings.currency);
},
),
const Divider(),
// Business Settings Section
_buildSectionHeader(context, 'Business Settings'),
ListTile(
leading: const Icon(Icons.store),
title: const Text('Store Name'),
subtitle: Text(settings.storeName),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showStoreNameDialog(context, ref, settings.storeName);
},
),
ListTile(
leading: const Icon(Icons.percent),
title: const Text('Tax Rate'),
subtitle: Text('${(settings.taxRate * 100).toStringAsFixed(1)}%'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showTaxRateDialog(context, ref, settings.taxRate);
},
),
const Divider(),
// Data Management Section
_buildSectionHeader(context, 'Data Management'),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync Data'),
subtitle: settings.lastSyncAt != null
? Text('Last synced: ${_formatDateTime(settings.lastSyncAt!)}')
: const Text('Never synced'),
trailing: const Icon(Icons.cloud_upload),
onTap: () => _performSync(context, ref),
),
ListTile(
leading: const Icon(Icons.delete_sweep),
title: const Text('Clear Cache'),
subtitle: const Text('Remove cached images and data'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showClearCacheDialog(context),
),
const Divider(),
// About Section
_buildSectionHeader(context, 'About'),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('App Version'),
subtitle: Text(AppConstants.appVersion),
),
ListTile(
leading: const Icon(Icons.business),
title: const Text('About ${AppConstants.appName}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showAboutDialog(context),
),
],
);
},
),
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light:
return 'Light';
case ThemeMode.dark:
return 'Dark';
case ThemeMode.system:
return 'System';
}
}
String _getLanguageName(String code) {
switch (code) {
case 'en':
return 'English';
case 'es':
return 'Spanish';
case 'fr':
return 'French';
default:
return code;
}
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
void _showThemeDialog(BuildContext context, WidgetRef ref, ThemeMode currentMode) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Theme'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<ThemeMode>(
title: const Text('Light'),
value: ThemeMode.light,
groupValue: currentMode,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context);
}
},
),
RadioListTile<ThemeMode>(
title: const Text('Dark'),
value: ThemeMode.dark,
groupValue: currentMode,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context);
}
},
),
RadioListTile<ThemeMode>(
title: const Text('System'),
value: ThemeMode.system,
groupValue: currentMode,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context);
}
},
),
],
),
),
);
}
void _showLanguageDialog(BuildContext context, WidgetRef ref, String currentLanguage) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Language'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('English'),
value: 'en',
groupValue: currentLanguage,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('Spanish'),
value: 'es',
groupValue: currentLanguage,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('French'),
value: 'fr',
groupValue: currentLanguage,
onChanged: (value) {
if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context);
}
},
),
],
),
),
);
}
void _showCurrencyDialog(BuildContext context, WidgetRef ref, String currentCurrency) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Currency'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('USD - US Dollar'),
value: 'USD',
groupValue: currentCurrency,
onChanged: (value) {
if (value != null) {
// TODO: Implement currency update
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('EUR - Euro'),
value: 'EUR',
groupValue: currentCurrency,
onChanged: (value) {
if (value != null) {
// TODO: Implement currency update
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('GBP - British Pound'),
value: 'GBP',
groupValue: currentCurrency,
onChanged: (value) {
if (value != null) {
// TODO: Implement currency update
Navigator.pop(context);
}
},
),
],
),
),
);
}
void _showStoreNameDialog(BuildContext context, WidgetRef ref, String currentName) {
final controller = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Store Name'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Store Name',
hintText: 'Enter store name',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
// TODO: Implement store name update
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Store name updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _showTaxRateDialog(BuildContext context, WidgetRef ref, double currentRate) {
final controller = TextEditingController(
text: (currentRate * 100).toStringAsFixed(1),
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tax Rate'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Tax Rate (%)',
hintText: 'Enter tax rate',
suffixText: '%',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
// TODO: Implement tax rate update
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tax rate updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _performSync(BuildContext context, WidgetRef ref) async {
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Syncing data...'),
],
),
),
),
),
);
// Perform sync
await Future.delayed(const Duration(seconds: 2)); // Simulated delay
// Close loading dialog
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Data synced successfully')),
);
}
}
void _showClearCacheDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Cache'),
content: const Text(
'This will remove all cached images and data. The app may need to reload content from the server.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cache cleared')),
);
},
child: const Text('Clear'),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
showAboutDialog(
context: context,
applicationName: AppConstants.appName,
applicationVersion: AppConstants.appVersion,
applicationIcon: const Icon(Icons.store, size: 48),
children: [
const Text(
'A modern Point of Sale application built with Flutter.',
),
const SizedBox(height: 16),
const Text(
'Features:\n'
'• Product management\n'
'• Category organization\n'
'• Shopping cart\n'
'• Transaction processing\n'
'• Offline-first architecture',
),
],
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'settings_provider.dart';
part 'language_provider.g.dart';
/// Language/locale provider
/// Extracts language from settings for easy access
@riverpod
String appLanguage(Ref ref) {
final settingsAsync = ref.watch(settingsProvider);
return settingsAsync.when(
data: (settings) => settings.language,
loading: () => 'en',
error: (_, __) => 'en',
);
}
/// Supported languages provider
@riverpod
List<LanguageOption> supportedLanguages(Ref ref) {
return const [
LanguageOption(code: 'en', name: 'English', nativeName: 'English'),
LanguageOption(code: 'es', name: 'Spanish', nativeName: 'Español'),
LanguageOption(code: 'fr', name: 'French', nativeName: 'Français'),
LanguageOption(code: 'de', name: 'German', nativeName: 'Deutsch'),
LanguageOption(code: 'it', name: 'Italian', nativeName: 'Italiano'),
LanguageOption(code: 'pt', name: 'Portuguese', nativeName: 'Português'),
LanguageOption(code: 'zh', name: 'Chinese', nativeName: '中文'),
LanguageOption(code: 'ja', name: 'Japanese', nativeName: '日本語'),
LanguageOption(code: 'ko', name: 'Korean', nativeName: '한국어'),
LanguageOption(code: 'ar', name: 'Arabic', nativeName: 'العربية'),
];
}
/// Language option model
class LanguageOption {
final String code;
final String name;
final String nativeName;
const LanguageOption({
required this.code,
required this.name,
required this.nativeName,
});
}

View File

@@ -0,0 +1,111 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'language_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Language/locale provider
/// Extracts language from settings for easy access
@ProviderFor(appLanguage)
const appLanguageProvider = AppLanguageProvider._();
/// Language/locale provider
/// Extracts language from settings for easy access
final class AppLanguageProvider
extends $FunctionalProvider<String, String, String>
with $Provider<String> {
/// Language/locale provider
/// Extracts language from settings for easy access
const AppLanguageProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLanguageProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLanguageHash();
@$internal
@override
$ProviderElement<String> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
String create(Ref ref) {
return appLanguage(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$appLanguageHash() => r'c5bfde42820d2fa742b4c875b91a0081ae235d41';
/// Supported languages provider
@ProviderFor(supportedLanguages)
const supportedLanguagesProvider = SupportedLanguagesProvider._();
/// Supported languages provider
final class SupportedLanguagesProvider
extends
$FunctionalProvider<
List<LanguageOption>,
List<LanguageOption>,
List<LanguageOption>
>
with $Provider<List<LanguageOption>> {
/// Supported languages provider
const SupportedLanguagesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'supportedLanguagesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$supportedLanguagesHash();
@$internal
@override
$ProviderElement<List<LanguageOption>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<LanguageOption> create(Ref ref) {
return supportedLanguages(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<LanguageOption> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<LanguageOption>>(value),
);
}
}
String _$supportedLanguagesHash() =>
r'c4b8224c1504112ce36de33ca7d3cf34d785a120';

View File

@@ -0,0 +1,5 @@
/// Export all settings providers
export 'settings_datasource_provider.dart';
export 'settings_provider.dart';
export 'theme_provider.dart';
export 'language_provider.dart';

View File

@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/datasources/settings_local_datasource.dart';
import '../../../../core/database/hive_database.dart';
import '../../data/models/app_settings_model.dart';
part 'settings_datasource_provider.g.dart';
/// Provider for settings local data source
/// This is kept alive as it's a dependency injection provider
@Riverpod(keepAlive: true)
SettingsLocalDataSource settingsLocalDataSource(Ref ref) {
final box = HiveDatabase.instance.getBox<AppSettingsModel>('settings');
return SettingsLocalDataSourceImpl(box);
}

View File

@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_datasource_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for settings local data source
/// This is kept alive as it's a dependency injection provider
@ProviderFor(settingsLocalDataSource)
const settingsLocalDataSourceProvider = SettingsLocalDataSourceProvider._();
/// Provider for settings local data source
/// This is kept alive as it's a dependency injection provider
final class SettingsLocalDataSourceProvider
extends
$FunctionalProvider<
SettingsLocalDataSource,
SettingsLocalDataSource,
SettingsLocalDataSource
>
with $Provider<SettingsLocalDataSource> {
/// Provider for settings local data source
/// This is kept alive as it's a dependency injection provider
const SettingsLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'settingsLocalDataSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$settingsLocalDataSourceHash();
@$internal
@override
$ProviderElement<SettingsLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
SettingsLocalDataSource create(Ref ref) {
return settingsLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SettingsLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SettingsLocalDataSource>(value),
);
}
}
String _$settingsLocalDataSourceHash() =>
r'fe7c05c34da176079f5bb95cc3a410d5fb5f3f68';

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/app_settings.dart';
import '../../../../core/constants/app_constants.dart';
part 'settings_provider.g.dart';
/// Provider for app settings
@riverpod
class Settings extends _$Settings {
@override
Future<AppSettings> build() async {
// TODO: Implement with repository
// Return default settings for now
return const AppSettings(
themeMode: ThemeMode.system,
language: AppConstants.defaultLanguage,
currency: AppConstants.defaultCurrency,
taxRate: AppConstants.defaultTaxRate,
storeName: AppConstants.appName,
enableSync: true,
);
}
Future<void> updateTheme(ThemeMode mode) async {
final current = state.value;
if (current != null) {
final updated = current.copyWith(themeMode: mode);
state = AsyncValue.data(updated);
// TODO: Persist to repository
}
}
Future<void> updateLanguage(String language) async {
final current = state.value;
if (current != null) {
final updated = current.copyWith(language: language);
state = AsyncValue.data(updated);
// TODO: Persist to repository
}
}
Future<void> updateLastSyncTime() async {
final current = state.value;
if (current != null) {
final updated = current.copyWith(lastSyncAt: DateTime.now());
state = AsyncValue.data(updated);
// TODO: Persist to repository
}
}
}

View File

@@ -0,0 +1,60 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for app settings
@ProviderFor(Settings)
const settingsProvider = SettingsProvider._();
/// Provider for app settings
final class SettingsProvider
extends $AsyncNotifierProvider<Settings, AppSettings> {
/// Provider for app settings
const SettingsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'settingsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$settingsHash();
@$internal
@override
Settings create() => Settings();
}
String _$settingsHash() => r'17065d5a6818ea746a031f33ff7f4fb9ab111075';
/// Provider for app settings
abstract class _$Settings extends $AsyncNotifier<AppSettings> {
FutureOr<AppSettings> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<AppSettings>, AppSettings>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AppSettings>, AppSettings>,
AsyncValue<AppSettings>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'settings_provider.dart';
part 'theme_provider.g.dart';
/// Theme mode provider from theme_provider
/// Extracts theme mode from settings for easy access
@riverpod
ThemeMode themeModeFromTheme(Ref ref) {
final settingsAsync = ref.watch(settingsProvider);
return settingsAsync.when(
data: (settings) => settings.themeMode,
loading: () => ThemeMode.system,
error: (_, __) => ThemeMode.system,
);
}
/// Provider to check if dark mode is active
@riverpod
bool isDarkMode(Ref ref) {
final mode = ref.watch(themeModeFromThemeProvider);
return mode == ThemeMode.dark;
}
/// Provider to check if light mode is active
@riverpod
bool isLightMode(Ref ref) {
final mode = ref.watch(themeModeFromThemeProvider);
return mode == ThemeMode.light;
}
/// Provider to check if system theme is active
@riverpod
bool isSystemTheme(Ref ref) {
final mode = ref.watch(themeModeFromThemeProvider);
return mode == ThemeMode.system;
}

View File

@@ -0,0 +1,194 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Theme mode provider from theme_provider
/// Extracts theme mode from settings for easy access
@ProviderFor(themeModeFromTheme)
const themeModeFromThemeProvider = ThemeModeFromThemeProvider._();
/// Theme mode provider from theme_provider
/// Extracts theme mode from settings for easy access
final class ThemeModeFromThemeProvider
extends $FunctionalProvider<ThemeMode, ThemeMode, ThemeMode>
with $Provider<ThemeMode> {
/// Theme mode provider from theme_provider
/// Extracts theme mode from settings for easy access
const ThemeModeFromThemeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeModeFromThemeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeModeFromThemeHash();
@$internal
@override
$ProviderElement<ThemeMode> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ThemeMode create(Ref ref) {
return themeModeFromTheme(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeMode>(value),
);
}
}
String _$themeModeFromThemeHash() =>
r'a906c8a301f2ac5e4b83009b239eb3a6f049a1b1';
/// Provider to check if dark mode is active
@ProviderFor(isDarkMode)
const isDarkModeProvider = IsDarkModeProvider._();
/// Provider to check if dark mode is active
final class IsDarkModeProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Provider to check if dark mode is active
const IsDarkModeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isDarkModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isDarkModeHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isDarkMode(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isDarkModeHash() => r'f8c2497b2bae2519f51da2543e4fc7e99a4f5b8c';
/// Provider to check if light mode is active
@ProviderFor(isLightMode)
const isLightModeProvider = IsLightModeProvider._();
/// Provider to check if light mode is active
final class IsLightModeProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Provider to check if light mode is active
const IsLightModeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isLightModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isLightModeHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isLightMode(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isLightModeHash() => r'0aac9dd8e1cb428913b5d463635dcc7b9315f031';
/// Provider to check if system theme is active
@ProviderFor(isSystemTheme)
const isSystemThemeProvider = IsSystemThemeProvider._();
/// Provider to check if system theme is active
final class IsSystemThemeProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Provider to check if system theme is active
const IsSystemThemeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isSystemThemeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isSystemThemeHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isSystemTheme(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isSystemThemeHash() => r'80e8bef39cde0b6f1b3e074483ea30d5a64aeade';