diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 5377c46..495f65c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -34,7 +34,7 @@ jobs:
- name: 🐦 Setup Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: '3.38.x'
+ flutter-version: '3.41.x'
channel: 'stable'
cache: true
@@ -76,7 +76,7 @@ jobs:
- name: 🐦 Setup Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: '3.38.x'
+ flutter-version: '3.41.x'
channel: 'stable'
cache: true
diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml
index 9767cf7..6ecfa8f 100644
--- a/.github/workflows/pr-checks.yaml
+++ b/.github/workflows/pr-checks.yaml
@@ -32,7 +32,7 @@ jobs:
- name: 🐦 Setup Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: '3.38.x'
+ flutter-version: '3.41.x'
channel: 'stable'
- name: 📦 Get dependencies
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 2aafd32..f83b388 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -31,7 +31,7 @@ jobs:
- name: 🐦 Setup Flutter
uses: subosito/flutter-action@v2
with:
- flutter-version: '3.38.x'
+ flutter-version: '3.41.x'
channel: 'stable'
cache: true
@@ -123,7 +123,7 @@ jobs:
# - name: �🐦 Setup Flutter
# uses: subosito/flutter-action@v2
# with:
- # flutter-version: '3.38.x'
+ # flutter-version: '3.41.x'
# channel: 'stable'
# cache: true
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 94a87de..8dbc6ce 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -13,7 +13,6 @@ linter:
rules:
# Error rules
avoid_print: true
- avoid_returning_null_for_future: true
cancel_subscriptions: true
close_sinks: true
valid_regexps: true
diff --git a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist
+++ b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift
index 6266644..c30b367 100644
--- a/apps/mobile/ios/Runner/AppDelegate.swift
+++ b/apps/mobile/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist
index 5f111a8..a1530b2 100644
--- a/apps/mobile/ios/Runner/Info.plist
+++ b/apps/mobile/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,37 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ LSSupportsOpeningDocumentsInPlace
+
+ NSCameraUsageDescription
+ This app needs camera access to scan barcodes for adding items to your collection
+ NSPhotoLibraryUsageDescription
+ This app needs photo library access to add images to your items
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UIFileSharingEnabled
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,20 +74,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
-
-
- NSCameraUsageDescription
- This app needs camera access to scan barcodes for adding items to your collection
-
- NSPhotoLibraryUsageDescription
- This app needs photo library access to add images to your items
- UIFileSharingEnabled
-
- LSSupportsOpeningDocumentsInPlace
-
diff --git a/apps/mobile/lib/features/items/presentation/providers/price_tracking_provider.dart b/apps/mobile/lib/features/items/presentation/providers/price_tracking_provider.dart
new file mode 100644
index 0000000..e5a5fb7
--- /dev/null
+++ b/apps/mobile/lib/features/items/presentation/providers/price_tracking_provider.dart
@@ -0,0 +1,8 @@
+import 'package:collection_tracker/core/providers/providers.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+final itemPriceHistoryProvider =
+ StreamProvider.family, String>((ref, itemId) {
+ final repository = ref.watch(itemRepositoryProvider);
+ return repository.watchPriceHistory(itemId);
+ });
diff --git a/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart b/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart
index a1ba1c8..9a0a271 100644
--- a/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart
+++ b/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart
@@ -22,6 +22,9 @@ Future createItem(
String? coverImageUrl,
String? coverImagePath,
List tags = const [],
+ double? purchasePrice,
+ double? currentValue,
+ DateTime? purchaseDate,
}) async {
final repository = ref.read(itemRepositoryProvider);
@@ -34,6 +37,9 @@ Future createItem(
coverImageUrl: coverImageUrl,
coverImagePath: coverImagePath,
tags: tags,
+ purchasePrice: purchasePrice,
+ currentValue: currentValue,
+ purchaseDate: purchaseDate,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
diff --git a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart
index e68fa60..5ee45ef 100644
--- a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart
+++ b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart
@@ -27,6 +27,9 @@ class _AddItemScreenState extends ConsumerState {
final _titleController = TextEditingController();
final _barcodeController = TextEditingController();
final _descriptionController = TextEditingController();
+ final _purchasePriceController = TextEditingController();
+ final _currentValueController = TextEditingController();
+ final _purchaseDateController = TextEditingController();
final _imageStorageService = ImageStorageService();
@@ -35,12 +38,16 @@ class _AddItemScreenState extends ConsumerState {
String? _imagePath;
String? _coverImageUrl;
List _tags = const [];
+ DateTime? _selectedPurchaseDate;
@override
void dispose() {
_titleController.dispose();
_barcodeController.dispose();
_descriptionController.dispose();
+ _purchasePriceController.dispose();
+ _currentValueController.dispose();
+ _purchaseDateController.dispose();
super.dispose();
}
@@ -184,6 +191,63 @@ class _AddItemScreenState extends ConsumerState {
label: 'Tags (optional)',
hintText: 'e.g., Rare, Completed Set',
),
+ const SizedBox(height: 16),
+
+ Row(
+ children: [
+ Expanded(
+ child: TextFormField(
+ controller: _purchasePriceController,
+ decoration: const InputDecoration(
+ labelText: 'Purchase Price',
+ prefixText: '\$',
+ prefixIcon: Icon(Icons.attach_money),
+ ),
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ validator: _validatePriceInput,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: TextFormField(
+ controller: _currentValueController,
+ decoration: const InputDecoration(
+ labelText: 'Current Value',
+ prefixText: '\$',
+ prefixIcon: Icon(Icons.show_chart),
+ ),
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ validator: _validatePriceInput,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+
+ TextFormField(
+ controller: _purchaseDateController,
+ readOnly: true,
+ decoration: InputDecoration(
+ labelText: 'Purchase Date (optional)',
+ prefixIcon: const Icon(Icons.calendar_today),
+ suffixIcon: _selectedPurchaseDate == null
+ ? null
+ : IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ setState(() {
+ _selectedPurchaseDate = null;
+ _purchaseDateController.clear();
+ });
+ },
+ ),
+ ),
+ onTap: _pickPurchaseDate,
+ ),
const SizedBox(height: 24),
// Add button
@@ -308,6 +372,9 @@ class _AddItemScreenState extends ConsumerState {
coverImageUrl: _coverImageUrl,
coverImagePath: _imagePath,
tags: _tags,
+ purchasePrice: _parsePriceInput(_purchasePriceController.text),
+ currentValue: _parsePriceInput(_currentValueController.text),
+ purchaseDate: _selectedPurchaseDate,
).future,
);
@@ -334,4 +401,39 @@ class _AddItemScreenState extends ConsumerState {
}
}
}
+
+ String? _validatePriceInput(String? value) {
+ if (value == null || value.trim().isEmpty) return null;
+ final parsed = _parsePriceInput(value);
+ if (parsed == null) return 'Invalid price';
+ if (parsed < 0) return 'Must be positive';
+ return null;
+ }
+
+ double? _parsePriceInput(String raw) {
+ final normalized = raw.trim().replaceAll(',', '');
+ if (normalized.isEmpty) return null;
+ return double.tryParse(normalized);
+ }
+
+ Future _pickPurchaseDate() async {
+ final picked = await showDatePicker(
+ context: context,
+ initialDate: _selectedPurchaseDate ?? DateTime.now(),
+ firstDate: DateTime(1900),
+ lastDate: DateTime.now().add(const Duration(days: 3650)),
+ );
+
+ if (picked == null || !mounted) return;
+ setState(() {
+ _selectedPurchaseDate = picked;
+ _purchaseDateController.text = _formatDate(picked);
+ });
+ }
+
+ String _formatDate(DateTime date) {
+ final month = date.month.toString().padLeft(2, '0');
+ final day = date.day.toString().padLeft(2, '0');
+ return '${date.year}-$month-$day';
+ }
}
diff --git a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart
index 4295bc7..c64cb78 100644
--- a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart
+++ b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart
@@ -20,6 +20,9 @@ class _EditItemScreenState extends ConsumerState {
final _titleController = TextEditingController();
final _barcodeController = TextEditingController();
final _descriptionController = TextEditingController();
+ final _purchasePriceController = TextEditingController();
+ final _currentValueController = TextEditingController();
+ final _purchaseDateController = TextEditingController();
final _notesController = TextEditingController();
final _locationController = TextEditingController();
final _quantityController = TextEditingController();
@@ -29,6 +32,7 @@ class _EditItemScreenState extends ConsumerState {
Item? _item;
ItemCondition? _selectedCondition;
List _tags = const [];
+ DateTime? _selectedPurchaseDate;
@override
void initState() {
@@ -40,6 +44,9 @@ class _EditItemScreenState extends ConsumerState {
_titleController.dispose();
_barcodeController.dispose();
_descriptionController.dispose();
+ _purchasePriceController.dispose();
+ _currentValueController.dispose();
+ _purchaseDateController.dispose();
_notesController.dispose();
_locationController.dispose();
_quantityController.dispose();
@@ -64,6 +71,16 @@ class _EditItemScreenState extends ConsumerState {
_titleController.text = item.title;
_barcodeController.text = item.barcode ?? '';
_descriptionController.text = item.description ?? '';
+ _purchasePriceController.text = item.purchasePrice != null
+ ? item.purchasePrice!.toStringAsFixed(2)
+ : '';
+ _currentValueController.text = item.currentValue != null
+ ? item.currentValue!.toStringAsFixed(2)
+ : '';
+ _selectedPurchaseDate = item.purchaseDate;
+ _purchaseDateController.text = _selectedPurchaseDate != null
+ ? _formatDate(_selectedPurchaseDate!)
+ : '';
_notesController.text = item.notes ?? '';
_locationController.text = item.location ?? '';
_quantityController.text = item.quantity.toString();
@@ -141,6 +158,63 @@ class _EditItemScreenState extends ConsumerState {
),
const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: TextFormField(
+ controller: _purchasePriceController,
+ decoration: const InputDecoration(
+ labelText: 'Purchase Price',
+ prefixText: '\$',
+ prefixIcon: Icon(Icons.attach_money),
+ ),
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ validator: _validatePriceInput,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: TextFormField(
+ controller: _currentValueController,
+ decoration: const InputDecoration(
+ labelText: 'Current Value',
+ prefixText: '\$',
+ prefixIcon: Icon(Icons.show_chart),
+ ),
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ validator: _validatePriceInput,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+
+ TextFormField(
+ controller: _purchaseDateController,
+ readOnly: true,
+ decoration: InputDecoration(
+ labelText: 'Purchase Date (optional)',
+ prefixIcon: const Icon(Icons.calendar_today),
+ suffixIcon: _selectedPurchaseDate == null
+ ? null
+ : IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ setState(() {
+ _selectedPurchaseDate = null;
+ _purchaseDateController.clear();
+ });
+ },
+ ),
+ ),
+ onTap: _pickPurchaseDate,
+ ),
+ const SizedBox(height: 16),
+
// Condition selector
DropdownButtonFormField(
initialValue: _selectedCondition,
@@ -261,6 +335,9 @@ class _EditItemScreenState extends ConsumerState {
quantity: int.parse(_quantityController.text),
condition: _selectedCondition,
tags: _tags,
+ purchasePrice: _parsePriceInput(_purchasePriceController.text),
+ currentValue: _parsePriceInput(_currentValueController.text),
+ purchaseDate: _selectedPurchaseDate,
);
await ref.read(updateItemProvider(updated).future);
@@ -288,4 +365,45 @@ class _EditItemScreenState extends ConsumerState {
}
}
}
+
+ String? _validatePriceInput(String? value) {
+ if (value == null || value.trim().isEmpty) {
+ return null;
+ }
+ final parsed = _parsePriceInput(value);
+ if (parsed == null) {
+ return 'Invalid price';
+ }
+ if (parsed < 0) {
+ return 'Must be positive';
+ }
+ return null;
+ }
+
+ double? _parsePriceInput(String raw) {
+ final normalized = raw.trim().replaceAll(',', '');
+ if (normalized.isEmpty) return null;
+ return double.tryParse(normalized);
+ }
+
+ Future _pickPurchaseDate() async {
+ final initialDate = _selectedPurchaseDate ?? DateTime.now();
+ final picked = await showDatePicker(
+ context: context,
+ initialDate: initialDate,
+ firstDate: DateTime(1900),
+ lastDate: DateTime.now().add(const Duration(days: 3650)),
+ );
+ if (picked == null || !mounted) return;
+ setState(() {
+ _selectedPurchaseDate = picked;
+ _purchaseDateController.text = _formatDate(picked);
+ });
+ }
+
+ String _formatDate(DateTime date) {
+ final month = date.month.toString().padLeft(2, '0');
+ final day = date.day.toString().padLeft(2, '0');
+ return '${date.year}-$month-$day';
+ }
}
diff --git a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart
index 7732ffe..e51df94 100644
--- a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart
+++ b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart
@@ -1,10 +1,13 @@
import 'dart:io';
+import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
+import 'package:domain/domain.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import '../providers/price_tracking_provider.dart';
import '../view_models/items_view_model.dart';
class ItemDetailScreen extends ConsumerWidget {
@@ -27,6 +30,7 @@ class ItemDetailScreen extends ConsumerWidget {
}
final theme = Theme.of(context);
+ final priceHistoryAsync = ref.watch(itemPriceHistoryProvider(item.id));
return Scaffold(
appBar: AppBar(
@@ -181,6 +185,118 @@ class ItemDetailScreen extends ConsumerWidget {
const SizedBox(height: 16),
],
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ 'Price Tracking',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const Spacer(),
+ TextButton.icon(
+ onPressed: () =>
+ _showUpdateCurrentValueDialog(
+ context,
+ ref,
+ item,
+ ),
+ icon: const Icon(Icons.show_chart),
+ label: const Text('Update'),
+ ),
+ ],
+ ),
+ if (item.currentValue != null) ...[
+ const SizedBox(height: 4),
+ Text(
+ _formatCurrency(item.currentValue!),
+ style: theme.textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ ] else ...[
+ const SizedBox(height: 4),
+ Text(
+ 'No current value set',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ const SizedBox(height: 12),
+ priceHistoryAsync.when(
+ data: (history) {
+ if (history.isEmpty) {
+ return Text(
+ 'No historical points yet. Update current value to start tracking.',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ );
+ }
+
+ final recent = history.reversed
+ .take(5)
+ .toList();
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _PriceHistoryChart(points: history),
+ const SizedBox(height: 12),
+ ...recent.map(
+ (entry) => Padding(
+ padding: const EdgeInsets.only(
+ bottom: 6,
+ ),
+ child: Row(
+ children: [
+ Text(
+ _formatDate(entry.$1),
+ style: theme.textTheme.bodySmall,
+ ),
+ const Spacer(),
+ Text(
+ _formatCurrency(entry.$2),
+ style: theme.textTheme.bodyMedium
+ ?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ loading: () => const SizedBox(
+ height: 80,
+ child: Center(
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ ),
+ ),
+ error: (_, _) => Text(
+ 'Unable to load price history',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+
// Details Card
Card(
child: Padding(
@@ -220,6 +336,12 @@ class ItemDetailScreen extends ConsumerWidget {
value:
'\$${item.purchasePrice!.toStringAsFixed(2)}',
),
+ if (item.currentValue != null)
+ _DetailRow(
+ label: 'Current Value',
+ value:
+ '\$${item.currentValue!.toStringAsFixed(2)}',
+ ),
if (item.purchaseDate != null)
_DetailRow(
label: 'Purchase Date',
@@ -276,6 +398,178 @@ class ItemDetailScreen extends ConsumerWidget {
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
+
+ String _formatCurrency(double value) {
+ return '\$${value.toStringAsFixed(2)}';
+ }
+
+ Future _showUpdateCurrentValueDialog(
+ BuildContext context,
+ WidgetRef ref,
+ Item item,
+ ) async {
+ var draftValue = item.currentValue?.toStringAsFixed(2) ?? '';
+
+ final value = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Update Current Value'),
+ content: TextFormField(
+ initialValue: draftValue,
+ autofocus: true,
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ decoration: const InputDecoration(
+ labelText: 'Current value',
+ prefixText: '\$',
+ hintText: '0.00',
+ ),
+ onChanged: (value) {
+ draftValue = value;
+ },
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () {
+ final parsed = double.tryParse(draftValue.trim());
+ if (parsed == null || parsed < 0) return;
+ Navigator.pop(context, parsed);
+ },
+ child: const Text('Save'),
+ ),
+ ],
+ ),
+ );
+
+ if (value == null || !context.mounted) return;
+
+ try {
+ await ref.read(
+ updateItemProvider(item.copyWith(currentValue: value)).future,
+ );
+ if (context.mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('Current value updated')));
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Failed to update value: $e'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+}
+
+class _PriceHistoryChart extends StatelessWidget {
+ final List<(DateTime, double)> points;
+
+ const _PriceHistoryChart({required this.points});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Container(
+ height: 110,
+ width: double.infinity,
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: CustomPaint(
+ painter: _PriceHistoryPainter(
+ points: points,
+ lineColor: theme.colorScheme.primary,
+ pointColor: theme.colorScheme.primaryContainer,
+ ),
+ ),
+ );
+ }
+}
+
+class _PriceHistoryPainter extends CustomPainter {
+ final List<(DateTime, double)> points;
+ final Color lineColor;
+ final Color pointColor;
+
+ _PriceHistoryPainter({
+ required this.points,
+ required this.lineColor,
+ required this.pointColor,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (points.isEmpty) return;
+
+ final minY = points.map((e) => e.$2).reduce(math.min);
+ final maxY = points.map((e) => e.$2).reduce(math.max);
+ final yRange = (maxY - minY).abs() < 0.001 ? 1.0 : maxY - minY;
+ final xStep = points.length == 1
+ ? size.width
+ : size.width / (points.length - 1);
+
+ final linePaint = Paint()
+ ..color = lineColor
+ ..strokeWidth = 2
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round;
+
+ final fillPaint = Paint()
+ ..shader = LinearGradient(
+ colors: [lineColor.withValues(alpha: 0.18), Colors.transparent],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
+
+ final linePath = Path();
+ final fillPath = Path();
+
+ for (var i = 0; i < points.length; i++) {
+ final x = xStep * i;
+ final normalizedY = (points[i].$2 - minY) / yRange;
+ final y = size.height - (normalizedY * (size.height - 10)) - 5;
+
+ if (i == 0) {
+ linePath.moveTo(x, y);
+ fillPath.moveTo(x, size.height);
+ fillPath.lineTo(x, y);
+ } else {
+ linePath.lineTo(x, y);
+ fillPath.lineTo(x, y);
+ }
+ }
+
+ final lastX = xStep * (points.length - 1);
+ fillPath.lineTo(lastX, size.height);
+ fillPath.close();
+
+ canvas.drawPath(fillPath, fillPaint);
+ canvas.drawPath(linePath, linePaint);
+
+ final pointPaint = Paint()..color = pointColor;
+ for (var i = 0; i < points.length; i++) {
+ final x = xStep * i;
+ final normalizedY = (points[i].$2 - minY) / yRange;
+ final y = size.height - (normalizedY * (size.height - 10)) - 5;
+ canvas.drawCircle(Offset(x, y), 3, pointPaint);
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _PriceHistoryPainter oldDelegate) {
+ return oldDelegate.points != points ||
+ oldDelegate.lineColor != lineColor ||
+ oldDelegate.pointColor != pointColor;
+ }
}
class _DetailRow extends StatelessWidget {
diff --git a/apps/mobile/lib/features/statistics/presentation/view_models/statistics_view_model.dart b/apps/mobile/lib/features/statistics/presentation/view_models/statistics_view_model.dart
index 852548d..60ee8d6 100644
--- a/apps/mobile/lib/features/statistics/presentation/view_models/statistics_view_model.dart
+++ b/apps/mobile/lib/features/statistics/presentation/view_models/statistics_view_model.dart
@@ -66,8 +66,9 @@ class StatisticsViewModel extends _$StatisticsViewModel {
if (item.isFavorite) {
favoriteCount++;
}
- if (item.purchasePrice != null) {
- totalValue += item.purchasePrice! * item.quantity;
+ final effectiveValue = item.currentValue ?? item.purchasePrice;
+ if (effectiveValue != null) {
+ totalValue += effectiveValue * item.quantity;
}
}
diff --git a/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart b/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart
index edc6f16..afb7a16 100644
--- a/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart
+++ b/apps/mobile/lib/features/statistics/presentation/views/statistics_screen.dart
@@ -70,7 +70,7 @@ class StatisticsScreen extends ConsumerWidget {
Expanded(
child: StatCard(
title: 'Total Value',
- value: '\$${stats.totalValue.toStringAsFixed(0)}',
+ value: '\$${stats.totalValue.toStringAsFixed(2)}',
icon: Icons.attach_money,
color: Colors.orange,
),
diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml
index 14ed4ae..5936d3c 100644
--- a/apps/mobile/pubspec.yaml
+++ b/apps/mobile/pubspec.yaml
@@ -5,7 +5,7 @@ version: 1.0.0+1
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
dependencies:
flutter:
@@ -64,8 +64,6 @@ dev_dependencies:
flutter_launcher_icons: ^0.14.4
flutter:
- config:
- enable-swift-package-manager: true
uses-material-design: true
generate: true
assets:
diff --git a/packages/common/env/pubspec.yaml b/packages/common/env/pubspec.yaml
index 774e7ee..c98eda7 100644
--- a/packages/common/env/pubspec.yaml
+++ b/packages/common/env/pubspec.yaml
@@ -4,7 +4,7 @@ version: 1.0.0
publish_to: 'none'
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/common/ui/pubspec.yaml b/packages/common/ui/pubspec.yaml
index 7bb9523..a634476 100644
--- a/packages/common/ui/pubspec.yaml
+++ b/packages/common/ui/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/common/utils/pubspec.yaml b/packages/common/utils/pubspec.yaml
index 33d95b9..4935834 100644
--- a/packages/common/utils/pubspec.yaml
+++ b/packages/common/utils/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/core/data/lib/src/repositories/item_repository_impl.dart b/packages/core/data/lib/src/repositories/item_repository_impl.dart
index 8219628..e9b649e 100644
--- a/packages/core/data/lib/src/repositories/item_repository_impl.dart
+++ b/packages/core/data/lib/src/repositories/item_repository_impl.dart
@@ -176,6 +176,13 @@ class ItemRepositoryImpl implements ItemRepository {
});
}
+ @override
+ Stream> watchPriceHistory(String itemId) {
+ return _dao
+ .watchPriceHistoryForItem(itemId)
+ .map((rows) => rows.map((row) => (row.recordedAt, row.value)).toList());
+ }
+
@override
Stream> watchTagsWithUsage() {
return _dao.watchTagsWithUsage();
diff --git a/packages/core/data/pubspec.yaml b/packages/core/data/pubspec.yaml
index d0d7486..8432892 100644
--- a/packages/core/data/pubspec.yaml
+++ b/packages/core/data/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/core/domain/lib/src/repositories/item_repository.dart b/packages/core/domain/lib/src/repositories/item_repository.dart
index 9034086..44c7d14 100644
--- a/packages/core/domain/lib/src/repositories/item_repository.dart
+++ b/packages/core/domain/lib/src/repositories/item_repository.dart
@@ -19,6 +19,7 @@ abstract class ItemRepository {
Stream> watchAllFavoriteItems();
Stream> watchAllWishlistItems();
Stream> watchItemsByTag(String tagName);
+ Stream> watchPriceHistory(String itemId);
Stream> watchTagsWithUsage();
Future>> searchItems({
diff --git a/packages/core/domain/pubspec.yaml b/packages/core/domain/pubspec.yaml
index e312ef1..b5eb91c 100644
--- a/packages/core/domain/pubspec.yaml
+++ b/packages/core/domain/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/analytics/lib/src/core/analytics_event.dart b/packages/integrations/analytics/lib/src/core/analytics_event.dart
index a10e940..c8e3570 100644
--- a/packages/integrations/analytics/lib/src/core/analytics_event.dart
+++ b/packages/integrations/analytics/lib/src/core/analytics_event.dart
@@ -68,7 +68,7 @@ abstract class AnalyticsEvent with _$AnalyticsEvent {
name: 'screen_view',
properties: {
'screen_name': screenName,
- if (screenClass != null) 'screen_class': screenClass,
+ 'screen_class': ?screenClass,
...?properties,
},
timestamp: DateTime.now(),
@@ -86,7 +86,7 @@ abstract class AnalyticsEvent with _$AnalyticsEvent {
name: 'button_clicked',
properties: {
'button_name': buttonName,
- if (screenName != null) 'screen_name': screenName,
+ 'screen_name': ?screenName,
...?properties,
},
timestamp: DateTime.now(),
diff --git a/packages/integrations/analytics/lib/src/events/app_events.dart b/packages/integrations/analytics/lib/src/events/app_events.dart
index 2d24b50..382dc56 100644
--- a/packages/integrations/analytics/lib/src/events/app_events.dart
+++ b/packages/integrations/analytics/lib/src/events/app_events.dart
@@ -10,7 +10,7 @@ class AppEvents {
return AnalyticsEvent.custom(
name: 'app_opened',
category: 'lifecycle',
- properties: {if (source != null) 'source': source, ...?properties},
+ properties: {'source': ?source, ...?properties},
);
}
@@ -41,11 +41,7 @@ class AppEvents {
return AnalyticsEvent.custom(
name: 'app_crashed',
category: 'error',
- properties: {
- 'error': error,
- if (stackTrace != null) 'stack_trace': stackTrace,
- ...?properties,
- },
+ properties: {'error': error, 'stack_trace': ?stackTrace, ...?properties},
);
}
@@ -61,8 +57,8 @@ class AppEvents {
category: 'error',
properties: {
'error': error,
- if (screen != null) 'screen': screen,
- if (context != null) 'context': context,
+ 'screen': ?screen,
+ 'context': ?context,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/commerce_events.dart b/packages/integrations/analytics/lib/src/events/commerce_events.dart
index b99b1ba..28c618b 100644
--- a/packages/integrations/analytics/lib/src/events/commerce_events.dart
+++ b/packages/integrations/analytics/lib/src/events/commerce_events.dart
@@ -16,8 +16,8 @@ class CommerceEvents {
properties: {
'product_id': productId,
'product_name': productName,
- if (category != null) 'category': category,
- if (price != null) 'price': price,
+ 'category': ?category,
+ 'price': ?price,
...?properties,
},
);
@@ -63,7 +63,7 @@ class CommerceEvents {
'plan_name': planName,
'price': price,
'currency': currency ?? 'USD',
- if (billingPeriod != null) 'billing_period': billingPeriod,
+ 'billing_period': ?billingPeriod,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/engagement_events.dart b/packages/integrations/analytics/lib/src/events/engagement_events.dart
index 098fc42..6d54b8e 100644
--- a/packages/integrations/analytics/lib/src/events/engagement_events.dart
+++ b/packages/integrations/analytics/lib/src/events/engagement_events.dart
@@ -16,8 +16,8 @@ class EngagementEvents {
properties: {
'query_length': query.length,
'result_count': resultCount,
- if (category != null) 'category': category,
- if (duration != null) 'duration_ms': duration,
+ 'category': ?category,
+ 'duration_ms': ?duration,
...?properties,
},
);
@@ -36,7 +36,7 @@ class EngagementEvents {
properties: {
'filter_type': filterType,
'filter_value': filterValue.toString(),
- if (resultCount != null) 'result_count': resultCount,
+ 'result_count': ?resultCount,
...?properties,
},
);
@@ -107,7 +107,7 @@ class EngagementEvents {
'content_type': contentType,
'content_id': contentId,
'rating': rating,
- if (maxRating != null) 'max_rating': maxRating,
+ 'max_rating': ?maxRating,
...?properties,
},
);
@@ -126,7 +126,7 @@ class EngagementEvents {
properties: {
'content_type': contentType,
'content_id': contentId,
- if (commentLength != null) 'comment_length': commentLength,
+ 'comment_length': ?commentLength,
...?properties,
},
);
@@ -145,7 +145,7 @@ class EngagementEvents {
properties: {
'tutorial_name': tutorialName,
'step_count': stepCount,
- if (duration != null) 'duration_ms': duration,
+ 'duration_ms': ?duration,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/form_events.dart b/packages/integrations/analytics/lib/src/events/form_events.dart
index 66c29b1..b80b1fe 100644
--- a/packages/integrations/analytics/lib/src/events/form_events.dart
+++ b/packages/integrations/analytics/lib/src/events/form_events.dart
@@ -11,11 +11,7 @@ class FormEvents {
return AnalyticsEvent.custom(
name: 'form_started',
category: 'form',
- properties: {
- 'form_name': formName,
- if (formId != null) 'form_id': formId,
- ...?properties,
- },
+ properties: {'form_name': formName, 'form_id': ?formId, ...?properties},
);
}
@@ -32,9 +28,9 @@ class FormEvents {
category: 'form',
properties: {
'form_name': formName,
- if (formId != null) 'form_id': formId,
- if (duration != null) 'duration_ms': duration,
- if (fieldCount != null) 'field_count': fieldCount,
+ 'form_id': ?formId,
+ 'duration_ms': ?duration,
+ 'field_count': ?fieldCount,
...?properties,
},
);
@@ -53,9 +49,9 @@ class FormEvents {
category: 'form',
properties: {
'form_name': formName,
- if (formId != null) 'form_id': formId,
- if (lastFieldIndex != null) 'last_field_index': lastFieldIndex,
- if (duration != null) 'duration_ms': duration,
+ 'form_id': ?formId,
+ 'last_field_index': ?lastFieldIndex,
+ 'duration_ms': ?duration,
...?properties,
},
);
@@ -110,7 +106,7 @@ class FormEvents {
properties: {
'form_name': formName,
'field_name': fieldName,
- if (duration != null) 'duration_ms': duration,
+ 'duration_ms': ?duration,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/media_events.dart b/packages/integrations/analytics/lib/src/events/media_events.dart
index 0a5b32a..fa11f01 100644
--- a/packages/integrations/analytics/lib/src/events/media_events.dart
+++ b/packages/integrations/analytics/lib/src/events/media_events.dart
@@ -17,7 +17,7 @@ class MediaEvents {
'media_type': mediaType,
'media_id': mediaId,
'media_title': mediaTitle,
- if (duration != null) 'duration_seconds': duration,
+ 'duration_seconds': ?duration,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/notification_events.dart b/packages/integrations/analytics/lib/src/events/notification_events.dart
index 9020b7c..b769887 100644
--- a/packages/integrations/analytics/lib/src/events/notification_events.dart
+++ b/packages/integrations/analytics/lib/src/events/notification_events.dart
@@ -13,7 +13,7 @@ class NotificationEvents {
category: 'notification',
properties: {
'notification_type': notificationType,
- if (campaignId != null) 'campaign_id': campaignId,
+ 'campaign_id': ?campaignId,
...?properties,
},
);
@@ -31,8 +31,8 @@ class NotificationEvents {
category: 'notification',
properties: {
'notification_type': notificationType,
- if (campaignId != null) 'campaign_id': campaignId,
- if (action != null) 'action': action,
+ 'campaign_id': ?campaignId,
+ 'action': ?action,
...?properties,
},
);
@@ -49,7 +49,7 @@ class NotificationEvents {
category: 'notification',
properties: {
'notification_type': notificationType,
- if (campaignId != null) 'campaign_id': campaignId,
+ 'campaign_id': ?campaignId,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/performance_events.dart b/packages/integrations/analytics/lib/src/events/performance_events.dart
index 6a589fe..fcac5b3 100644
--- a/packages/integrations/analytics/lib/src/events/performance_events.dart
+++ b/packages/integrations/analytics/lib/src/events/performance_events.dart
@@ -34,7 +34,7 @@ class PerformanceEvents {
properties: {
'endpoint': endpoint,
'error': error,
- if (statusCode != null) 'status_code': statusCode,
+ 'status_code': ?statusCode,
...?properties,
},
);
diff --git a/packages/integrations/analytics/lib/src/events/screen_events.dart b/packages/integrations/analytics/lib/src/events/screen_events.dart
index 1575663..4be9f4e 100644
--- a/packages/integrations/analytics/lib/src/events/screen_events.dart
+++ b/packages/integrations/analytics/lib/src/events/screen_events.dart
@@ -14,8 +14,8 @@ class ScreenEvents {
category: 'navigation',
properties: {
'screen_name': screenName,
- if (screenClass != null) 'screen_class': screenClass,
- if (previousScreen != null) 'previous_screen': previousScreen,
+ 'screen_class': ?screenClass,
+ 'previous_screen': ?previousScreen,
...?properties,
},
);
@@ -34,7 +34,7 @@ class ScreenEvents {
properties: {
'tab_name': tabName,
'tab_index': tabIndex,
- if (screenName != null) 'screen_name': screenName,
+ 'screen_name': ?screenName,
...?properties,
},
);
@@ -51,7 +51,7 @@ class ScreenEvents {
category: 'navigation',
properties: {
'modal_name': modalName,
- if (trigger != null) 'trigger': trigger,
+ 'trigger': ?trigger,
...?properties,
},
);
@@ -69,8 +69,8 @@ class ScreenEvents {
category: 'navigation',
properties: {
'modal_name': modalName,
- if (action != null) 'action': action,
- if (timeSpent != null) 'time_spent_ms': timeSpent,
+ 'action': ?action,
+ 'time_spent_ms': ?timeSpent,
...?properties,
},
);
@@ -85,11 +85,7 @@ class ScreenEvents {
return AnalyticsEvent.custom(
name: 'deep_link_opened',
category: 'navigation',
- properties: {
- 'deep_link': deepLink,
- if (source != null) 'source': source,
- ...?properties,
- },
+ properties: {'deep_link': deepLink, 'source': ?source, ...?properties},
);
}
}
diff --git a/packages/integrations/analytics/lib/src/events/social_events.dart b/packages/integrations/analytics/lib/src/events/social_events.dart
index 9111980..19273af 100644
--- a/packages/integrations/analytics/lib/src/events/social_events.dart
+++ b/packages/integrations/analytics/lib/src/events/social_events.dart
@@ -13,7 +13,7 @@ class SocialEvents {
category: 'social',
properties: {
'method': method,
- if (inviteCount != null) 'invite_count': inviteCount,
+ 'invite_count': ?inviteCount,
...?properties,
},
);
@@ -30,7 +30,7 @@ class SocialEvents {
category: 'social',
properties: {
'referral_code': referralCode,
- if (referrerId != null) 'referrer_id': referrerId,
+ 'referrer_id': ?referrerId,
...?properties,
},
);
@@ -47,7 +47,7 @@ class SocialEvents {
category: 'social',
properties: {
'profile_id': profileId,
- if (profileType != null) 'profile_type': profileType,
+ 'profile_type': ?profileType,
...?properties,
},
);
diff --git a/packages/integrations/analytics/pubspec.yaml b/packages/integrations/analytics/pubspec.yaml
index b02dbad..a99d4b3 100644
--- a/packages/integrations/analytics/pubspec.yaml
+++ b/packages/integrations/analytics/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/barcode_scanner/pubspec.yaml b/packages/integrations/barcode_scanner/pubspec.yaml
index f16e0a5..655044e 100644
--- a/packages/integrations/barcode_scanner/pubspec.yaml
+++ b/packages/integrations/barcode_scanner/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/database/lib/src/app_database.dart b/packages/integrations/database/lib/src/app_database.dart
index 7dce502..21f1eaa 100644
--- a/packages/integrations/database/lib/src/app_database.dart
+++ b/packages/integrations/database/lib/src/app_database.dart
@@ -7,14 +7,14 @@ import 'package:path_provider/path_provider.dart';
part 'app_database.g.dart';
@DriftDatabase(
- tables: [Collections, Items, Tags, ItemTags],
+ tables: [Collections, Items, Tags, ItemTags, ItemPriceHistory],
daos: [CollectionDao, ItemDao],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
- int get schemaVersion => 4;
+ int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@@ -27,6 +27,10 @@ class AppDatabase extends _$AppDatabase {
'CREATE INDEX idx_collections_name ON collections(name);',
);
await customStatement('CREATE INDEX idx_items_name ON items(title);');
+ await customStatement(
+ 'CREATE INDEX idx_item_price_history_item_time '
+ 'ON item_price_history(item_id, recorded_at DESC);',
+ );
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
@@ -41,6 +45,14 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(tags);
await m.createTable(itemTags);
}
+
+ if (from < 5) {
+ await m.createTable(itemPriceHistory);
+ await customStatement(
+ 'CREATE INDEX idx_item_price_history_item_time '
+ 'ON item_price_history(item_id, recorded_at DESC);',
+ );
+ }
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart
index dc16112..11aca3e 100644
--- a/packages/integrations/database/lib/src/daos/item_dao.dart
+++ b/packages/integrations/database/lib/src/daos/item_dao.dart
@@ -4,7 +4,7 @@ import 'package:drift/drift.dart';
part 'item_dao.g.dart';
-@DriftAccessor(tables: [Items, Collections, ItemTags, Tags])
+@DriftAccessor(tables: [Items, Collections, ItemTags, Tags, ItemPriceHistory])
class ItemDao extends DatabaseAccessor with _$ItemDaoMixin {
ItemDao(super.db);
@@ -150,6 +150,28 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin {
.watch();
}
+ // Get historical price points for an item (oldest first)
+ Future> getPriceHistoryForItem(String itemId) {
+ return (select(itemPriceHistory)
+ ..where((tbl) => tbl.itemId.equals(itemId))
+ ..orderBy([
+ (tbl) => OrderingTerm.asc(tbl.recordedAt),
+ (tbl) => OrderingTerm.asc(tbl.id),
+ ]))
+ .get();
+ }
+
+ // Watch historical price points for an item (oldest first)
+ Stream> watchPriceHistoryForItem(String itemId) {
+ return (select(itemPriceHistory)
+ ..where((tbl) => tbl.itemId.equals(itemId))
+ ..orderBy([
+ (tbl) => OrderingTerm.asc(tbl.recordedAt),
+ (tbl) => OrderingTerm.asc(tbl.id),
+ ]))
+ .watch();
+ }
+
// Get items that contain a specific tag
Future> getItemsByTag(String tagName) async {
final query =
@@ -360,6 +382,36 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin {
await _updateItemTags(item.id.value, tags);
}
+ final purchasePrice = item.purchasePrice.present
+ ? item.purchasePrice.value
+ : null;
+ final currentValue = item.currentValue.present
+ ? item.currentValue.value
+ : null;
+ final purchaseDate = item.purchaseDate.present
+ ? item.purchaseDate.value
+ : null;
+ final createdAt = item.createdAt.value;
+ final updatedAt = item.updatedAt.value;
+
+ if (purchasePrice != null) {
+ await _recordPricePoint(
+ itemId: item.id.value,
+ value: purchasePrice,
+ recordedAt: purchaseDate ?? createdAt,
+ source: 'purchase',
+ );
+ }
+
+ if (currentValue != null && currentValue != purchasePrice) {
+ await _recordPricePoint(
+ itemId: item.id.value,
+ value: currentValue,
+ recordedAt: updatedAt,
+ source: 'current',
+ );
+ }
+
final collection = await (select(
collections,
)..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull();
@@ -382,6 +434,9 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin {
// Update item with tags
Future updateItem(ItemsCompanion item, {List? tags}) {
return transaction(() async {
+ final existingItem = await getItemById(item.id.value);
+ if (existingItem == null) return 0;
+
final rowsAffected = await (update(
items,
)..where((tbl) => tbl.id.equals(item.id.value))).write(item);
@@ -390,10 +445,66 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin {
await _updateItemTags(item.id.value, tags);
}
+ if (rowsAffected > 0) {
+ final previousPurchasePrice = existingItem.purchasePrice;
+ final nextPurchasePrice = item.purchasePrice.present
+ ? item.purchasePrice.value
+ : previousPurchasePrice;
+
+ if (nextPurchasePrice != null &&
+ nextPurchasePrice != previousPurchasePrice) {
+ final recordedAt = item.purchaseDate.present
+ ? item.purchaseDate.value
+ : existingItem.purchaseDate;
+ await _recordPricePoint(
+ itemId: item.id.value,
+ value: nextPurchasePrice,
+ recordedAt: recordedAt ?? DateTime.now(),
+ source: 'purchase',
+ );
+ }
+
+ final previousCurrentValue = existingItem.currentValue;
+ final nextCurrentValue = item.currentValue.present
+ ? item.currentValue.value
+ : previousCurrentValue;
+ if (nextCurrentValue != null &&
+ nextCurrentValue != previousCurrentValue) {
+ final recordedAt = item.updatedAt.present
+ ? item.updatedAt.value
+ : DateTime.now();
+ await _recordPricePoint(
+ itemId: item.id.value,
+ value: nextCurrentValue,
+ recordedAt: recordedAt,
+ source: 'current',
+ );
+ }
+ }
+
return rowsAffected;
});
}
+ Future _recordPricePoint({
+ required String itemId,
+ required double value,
+ required DateTime recordedAt,
+ required String source,
+ }) async {
+ final id =
+ '${itemId}_${recordedAt.microsecondsSinceEpoch}_${DateTime.now().microsecondsSinceEpoch}';
+ await into(itemPriceHistory).insert(
+ ItemPriceHistoryCompanion.insert(
+ id: id,
+ itemId: itemId,
+ value: value,
+ recordedAt: recordedAt,
+ source: Value(source),
+ ),
+ );
+ }
+
Future _updateItemTags(String itemId, List tagNames) async {
// 1. Get or create tags
final tagIds = [];
diff --git a/packages/integrations/database/lib/src/tables/item_price_history_table.dart b/packages/integrations/database/lib/src/tables/item_price_history_table.dart
new file mode 100644
index 0000000..b2f7771
--- /dev/null
+++ b/packages/integrations/database/lib/src/tables/item_price_history_table.dart
@@ -0,0 +1,16 @@
+import 'package:drift/drift.dart';
+
+import 'items_table.dart';
+
+@DataClassName('ItemPriceHistoryData')
+class ItemPriceHistory extends Table {
+ TextColumn get id => text()();
+ TextColumn get itemId =>
+ text().references(Items, #id, onDelete: KeyAction.cascade)();
+ RealColumn get value => real()();
+ DateTimeColumn get recordedAt => dateTime()();
+ TextColumn get source => text().withDefault(const Constant('manual'))();
+
+ @override
+ Set get primaryKey => {id};
+}
diff --git a/packages/integrations/database/lib/src/tables/tables.dart b/packages/integrations/database/lib/src/tables/tables.dart
index 54bd61d..669dff1 100644
--- a/packages/integrations/database/lib/src/tables/tables.dart
+++ b/packages/integrations/database/lib/src/tables/tables.dart
@@ -2,3 +2,4 @@ export 'collections_table.dart';
export 'items_table.dart';
export 'tags_table.dart';
export 'item_tags_table.dart';
+export 'item_price_history_table.dart';
diff --git a/packages/integrations/database/pubspec.yaml b/packages/integrations/database/pubspec.yaml
index d1d0f48..66f069d 100644
--- a/packages/integrations/database/pubspec.yaml
+++ b/packages/integrations/database/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/database/test/database_test.dart b/packages/integrations/database/test/database_test.dart
index 552d36f..c00ae98 100644
--- a/packages/integrations/database/test/database_test.dart
+++ b/packages/integrations/database/test/database_test.dart
@@ -560,5 +560,77 @@ void main() {
);
expect(filtered.map((item) => item.title), isNot(contains('No Match')));
});
+
+ test('insert item records initial price history points', () async {
+ final now = DateTime.now();
+ await db.itemDao.insertItem(
+ ItemsCompanion.insert(
+ id: 'item-price-1',
+ collectionId: collectionId,
+ title: 'Price Seed',
+ purchasePrice: const Value(10.0),
+ purchaseDate: Value(now.subtract(const Duration(days: 1))),
+ currentValue: const Value(12.5),
+ createdAt: now,
+ updatedAt: now,
+ ),
+ );
+
+ final history = await db.itemDao.getPriceHistoryForItem('item-price-1');
+ expect(history.length, 2);
+ expect(history.map((entry) => entry.value), containsAll([10.0, 12.5]));
+ });
+
+ test('updating current value appends price history point', () async {
+ final now = DateTime.now();
+ await db.itemDao.insertItem(
+ ItemsCompanion.insert(
+ id: 'item-price-2',
+ collectionId: collectionId,
+ title: 'Track Current',
+ currentValue: const Value(20.0),
+ createdAt: now,
+ updatedAt: now,
+ ),
+ );
+
+ await db.itemDao.updateItem(
+ ItemsCompanion(
+ id: const Value('item-price-2'),
+ currentValue: const Value(24.0),
+ updatedAt: Value(now.add(const Duration(minutes: 1))),
+ ),
+ );
+
+ final history = await db.itemDao.getPriceHistoryForItem('item-price-2');
+ expect(history.length, 2);
+ expect(history.last.value, 24.0);
+ });
+
+ test('updating non-price fields does not add history point', () async {
+ final now = DateTime.now();
+ await db.itemDao.insertItem(
+ ItemsCompanion.insert(
+ id: 'item-price-3',
+ collectionId: collectionId,
+ title: 'No Price Change',
+ currentValue: const Value(30.0),
+ createdAt: now,
+ updatedAt: now,
+ ),
+ );
+
+ await db.itemDao.updateItem(
+ ItemsCompanion(
+ id: const Value('item-price-3'),
+ title: const Value('Renamed'),
+ updatedAt: Value(now.add(const Duration(minutes: 2))),
+ ),
+ );
+
+ final history = await db.itemDao.getPriceHistoryForItem('item-price-3');
+ expect(history.length, 1);
+ expect(history.first.value, 30.0);
+ });
});
}
diff --git a/packages/integrations/logger/pubspec.yaml b/packages/integrations/logger/pubspec.yaml
index 1b9cd8c..97bc1ff 100644
--- a/packages/integrations/logger/pubspec.yaml
+++ b/packages/integrations/logger/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/metadata_api/lib/src/clients/google_books_client.dart b/packages/integrations/metadata_api/lib/src/clients/google_books_client.dart
index 2da8f6e..b726a82 100644
--- a/packages/integrations/metadata_api/lib/src/clients/google_books_client.dart
+++ b/packages/integrations/metadata_api/lib/src/clients/google_books_client.dart
@@ -66,8 +66,8 @@ class GoogleBooksClient {
'maxResults': pageSize,
'printType': 'books',
if (_apiKey != null) 'key': _apiKey,
- if (langRestrict != null) 'langRestrict': langRestrict,
- if (orderBy != null) 'orderBy': orderBy,
+ 'langRestrict': ?langRestrict,
+ 'orderBy': ?orderBy,
};
final response = await _dio.get('/volumes', queryParameters: queryParams);
diff --git a/packages/integrations/metadata_api/lib/src/clients/tmdb_client.dart b/packages/integrations/metadata_api/lib/src/clients/tmdb_client.dart
index d688371..c31d552 100644
--- a/packages/integrations/metadata_api/lib/src/clients/tmdb_client.dart
+++ b/packages/integrations/metadata_api/lib/src/clients/tmdb_client.dart
@@ -69,8 +69,8 @@ class TMDBClient {
'query': query,
'page': page,
'include_adult': includeAdult,
- if (language != null) 'language': language,
- if (year != null) 'year': year,
+ 'language': ?language,
+ 'year': ?year,
};
final response = await _dio.get(
@@ -105,7 +105,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -157,7 +157,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'external_source': 'imdb_id',
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -195,7 +195,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'page': page,
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -223,7 +223,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'page': page,
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -251,7 +251,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'page': page,
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -279,7 +279,7 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'page': page,
- if (language != null) 'language': language,
+ 'language': ?language,
};
final response = await _dio.get(
@@ -316,14 +316,14 @@ class TMDBClient {
final queryParams = {
// 'api_key': _apiKey,
'page': page,
- if (language != null) 'language': language,
- if (sortBy != null) 'sort_by': sortBy,
+ 'language': ?language,
+ 'sort_by': ?sortBy,
if (yearFrom != null) 'primary_release_date.gte': '$yearFrom-01-01',
if (yearTo != null) 'primary_release_date.lte': '$yearTo-12-31',
if (withGenres != null && withGenres.isNotEmpty)
'with_genres': withGenres.join(','),
- if (voteAverageGte != null) 'vote_average.gte': voteAverageGte,
- if (voteCountGte != null) 'vote_count.gte': voteCountGte,
+ 'vote_average.gte': ?voteAverageGte,
+ 'vote_count.gte': ?voteCountGte,
};
final response = await _dio.get(
diff --git a/packages/integrations/metadata_api/pubspec.yaml b/packages/integrations/metadata_api/pubspec.yaml
index 64e1c9b..d3a43df 100644
--- a/packages/integrations/metadata_api/pubspec.yaml
+++ b/packages/integrations/metadata_api/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/payment/pubspec.yaml b/packages/integrations/payment/pubspec.yaml
index 122696b..9e9299a 100644
--- a/packages/integrations/payment/pubspec.yaml
+++ b/packages/integrations/payment/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
diff --git a/packages/integrations/storage/pubspec.yaml b/packages/integrations/storage/pubspec.yaml
index bd1c2a6..579204a 100644
--- a/packages/integrations/storage/pubspec.yaml
+++ b/packages/integrations/storage/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: none
resolution: workspace
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies:
@@ -15,7 +15,7 @@ dependencies:
path_provider: ^2.1.5
path: ^1.9.1
image: ^4.7.2
- file_picker: 10.3.7 # upgrade to latest after android plugin fixed
+ file_picker: ^10.3.10
share_plus: ^12.0.1
permission_handler: ^12.0.1
flutter_secure_storage: ^10.0.0
diff --git a/pubspec.yaml b/pubspec.yaml
index 639ea91..04f1585 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -4,7 +4,7 @@ version: 1.0.0
publish_to: 'none'
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
dev_dependencies:
commitlint_cli: ^0.8.1
@@ -27,10 +27,3 @@ workspace:
- packages/integrations/metadata_api
- packages/integrations/payment
- packages/integrations/storage
-
-dependency_overrides:
- analyzer: ^9.0.0
- dart_style: 3.1.3
- test: 1.29.0
- test_api: 0.7.9
- test_core: 0.6.15
diff --git a/scripts/create_package.sh b/scripts/create_package.sh
index e10a671..635f0d5 100755
--- a/scripts/create_package.sh
+++ b/scripts/create_package.sh
@@ -31,7 +31,7 @@ version: 1.0.0
publish_to: 'none'
environment:
- sdk: ^3.10.4
+ sdk: ^3.11.0
flutter: ">=1.17.0"
dependencies: