feat: add HelpText model, service, and controller for dynamic help text management
This commit is contained in:
@@ -38,6 +38,10 @@ class ReceiptImportApiPaths {
|
||||
static const unitMappings = '/receipt-import/unit-mappings';
|
||||
}
|
||||
|
||||
class HelpTextApiPaths {
|
||||
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
|
||||
}
|
||||
|
||||
class ReceiptAliasApiPaths {
|
||||
static const list = '/receipt-aliases';
|
||||
static String update(int id) => '/receipt-aliases/$id';
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'dart:developer' as developer;
|
||||
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../domain/help_text_content.dart';
|
||||
import '../domain/quick_import_result.dart';
|
||||
|
||||
/// Handles communication with the quick-import API endpoint.
|
||||
@@ -25,6 +26,38 @@ class ImportRepository {
|
||||
defaultValue: '/api',
|
||||
);
|
||||
|
||||
Future<HelpTextContent> fetchHelpTextByKey(
|
||||
String key, {
|
||||
String? token,
|
||||
}) async {
|
||||
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
|
||||
final response = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(
|
||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||
message: 'Kunde inte hämta hjälptext: ${response.body}',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final parsed = _parseResponse(response);
|
||||
if (parsed is! Map<String, dynamic>) {
|
||||
throw ApiException(
|
||||
type: ApiErrorType.unknown,
|
||||
message: 'Felaktigt svar vid hämtning av hjälptext.',
|
||||
);
|
||||
}
|
||||
|
||||
return HelpTextContent.fromJson(parsed);
|
||||
}
|
||||
|
||||
/// Upload a receipt file for parsing (Fas 6b).
|
||||
/// Returns a list of parsed receipt items.
|
||||
Future<List<ParsedReceiptItem>> importReceiptFile({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class HelpTextContent {
|
||||
final String key;
|
||||
final String scope;
|
||||
final String title;
|
||||
final String content;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const HelpTextContent({
|
||||
required this.key,
|
||||
required this.scope,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory HelpTextContent.fromJson(Map<String, dynamic> json) {
|
||||
return HelpTextContent(
|
||||
key: (json['key'] as String? ?? '').trim(),
|
||||
scope: (json['scope'] as String? ?? 'default').trim(),
|
||||
title: (json['title'] as String? ?? '').trim(),
|
||||
content: (json['content'] as String? ?? '').trim(),
|
||||
updatedAt: json['updatedAt'] is String
|
||||
? DateTime.tryParse(json['updatedAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import '../../pantry/data/pantry_providers.dart';
|
||||
import '../../pantry/domain/pantry_item.dart';
|
||||
import '../data/import_providers.dart';
|
||||
import '../data/receipt_import_session.dart';
|
||||
import '../domain/help_text_content.dart';
|
||||
import '../domain/parsed_receipt_item.dart';
|
||||
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||
import '../utils/receipt_import_utils.dart';
|
||||
@@ -37,6 +38,7 @@ class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||
class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
bool _isLoading = false;
|
||||
bool _isSaving = false;
|
||||
bool _isHelpLoading = false;
|
||||
PlatformFile? _pickedFile;
|
||||
bool _categoryLoadFailed = false;
|
||||
bool _globalProductsLoadFailed = false;
|
||||
@@ -460,6 +462,64 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showHelpForReceiptImport() async {
|
||||
if (_isHelpLoading) return;
|
||||
setState(() => _isHelpLoading = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final help = await repo.fetchHelpTextByKey('receipt_import', token: token);
|
||||
if (!mounted) return;
|
||||
await _showHelpDialog(help);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showGlobalErrorDialog(context, 'Kunde inte läsa hjälptexten just nu: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isHelpLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showHelpDialog(HelpTextContent help) {
|
||||
final updatedAt = help.updatedAt;
|
||||
final updatedAtText = updatedAt == null
|
||||
? null
|
||||
: '${updatedAt.year.toString().padLeft(4, '0')}-${updatedAt.month.toString().padLeft(2, '0')}-${updatedAt.day.toString().padLeft(2, '0')}';
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(help.title.isEmpty ? 'Hjälp: Kvittoimport' : help.title),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(help.content),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Scope: ${help.scope}')),
|
||||
if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Stäng'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteItem(int index) {
|
||||
final items = _items;
|
||||
if (items == null || index < 0 || index >= items.length) return;
|
||||
@@ -819,9 +879,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _isHelpLoading ? null : _showHelpForReceiptImport,
|
||||
icon: _isHelpLoading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.help_outline),
|
||||
label: const Text('Läs hjälp'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
|
||||
Reference in New Issue
Block a user