131 lines
3.7 KiB
Dart
131 lines
3.7 KiB
Dart
/// Domain models for catalog items shown in lists and detail screens.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// High-level grouping used for filters, icons, and accent colors.
|
|
enum ItemCategory {
|
|
design,
|
|
development,
|
|
productivity,
|
|
lifestyle,
|
|
}
|
|
|
|
extension ItemCategoryX on ItemCategory {
|
|
String get label => switch (this) {
|
|
ItemCategory.design => 'Design',
|
|
ItemCategory.development => 'Development',
|
|
ItemCategory.productivity => 'Productivity',
|
|
ItemCategory.lifestyle => 'Lifestyle',
|
|
};
|
|
|
|
IconData get icon => switch (this) {
|
|
ItemCategory.design => Icons.palette_outlined,
|
|
ItemCategory.development => Icons.code,
|
|
ItemCategory.productivity => Icons.bolt_outlined,
|
|
ItemCategory.lifestyle => Icons.spa_outlined,
|
|
};
|
|
|
|
/// Stable accent used by [Hero] tiles so the animation has a colored target.
|
|
Color get accent => switch (this) {
|
|
ItemCategory.design => const Color(0xFF7C3AED),
|
|
ItemCategory.development => const Color(0xFF0F766E),
|
|
ItemCategory.productivity => const Color(0xFFC2410C),
|
|
ItemCategory.lifestyle => const Color(0xFF0369A1),
|
|
};
|
|
|
|
static ItemCategory fromName(String name) {
|
|
return ItemCategory.values.firstWhere(
|
|
(category) => category.name == name,
|
|
orElse: () => ItemCategory.productivity,
|
|
);
|
|
}
|
|
|
|
static ItemCategory fromIndex(int index) {
|
|
return ItemCategory.values[index.abs() % ItemCategory.values.length];
|
|
}
|
|
}
|
|
|
|
/// A catalog entry displayed on Home / Search and opened on the detail route.
|
|
class Item {
|
|
const Item({
|
|
required this.id,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.description,
|
|
required this.category,
|
|
required this.createdAt,
|
|
});
|
|
|
|
final String id;
|
|
final String title;
|
|
final String subtitle;
|
|
final String description;
|
|
final ItemCategory category;
|
|
final DateTime createdAt;
|
|
|
|
/// Tag used by the list-to-detail [Hero] animation.
|
|
String get heroTag => 'item-hero-$id';
|
|
|
|
Item copyWith({
|
|
String? id,
|
|
String? title,
|
|
String? subtitle,
|
|
String? description,
|
|
ItemCategory? category,
|
|
DateTime? createdAt,
|
|
}) {
|
|
return Item(
|
|
id: id ?? this.id,
|
|
title: title ?? this.title,
|
|
subtitle: subtitle ?? this.subtitle,
|
|
description: description ?? this.description,
|
|
category: category ?? this.category,
|
|
createdAt: createdAt ?? this.createdAt,
|
|
);
|
|
}
|
|
|
|
/// Maps a JSONPlaceholder `/posts` payload (or any similar JSON object).
|
|
factory Item.fromJson(Map<String, dynamic> json) {
|
|
final id = json['id']?.toString() ?? '0';
|
|
final title = (json['title'] as String? ?? 'Untitled').trim();
|
|
final body = (json['body'] as String? ?? '').trim();
|
|
final numericId = int.tryParse(id) ?? 0;
|
|
|
|
return Item(
|
|
id: 'remote-$id',
|
|
title: _titleCase(title),
|
|
subtitle: _firstSentence(body),
|
|
description: body.replaceAll('\n', ' '),
|
|
category: ItemCategoryX.fromIndex(numericId),
|
|
createdAt: DateTime(2026, 1, 1).add(Duration(days: numericId)),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'title': title,
|
|
'subtitle': subtitle,
|
|
'description': description,
|
|
'category': category.name,
|
|
'createdAt': createdAt.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
static String _titleCase(String value) {
|
|
if (value.isEmpty) return value;
|
|
return value[0].toUpperCase() + value.substring(1);
|
|
}
|
|
|
|
static String _firstSentence(String value) {
|
|
if (value.isEmpty) return 'No summary available.';
|
|
final cleaned = value.replaceAll('\n', ' ');
|
|
final period = cleaned.indexOf('.');
|
|
if (period <= 0) {
|
|
return cleaned.length > 80 ? '${cleaned.substring(0, 77)}…' : cleaned;
|
|
}
|
|
return cleaned.substring(0, period + 1);
|
|
}
|
|
}
|