Skip to content

Commit

Permalink
Merge pull request #1922 from matthiasn/feat/category_search_create_f…
Browse files Browse the repository at this point in the history
…rom_picker

feat: category search create from picker
  • Loading branch information
matthiasn authored Feb 2, 2025
2 parents 330f82a + 2997fee commit 9a246cd
Show file tree
Hide file tree
Showing 31 changed files with 1,175 additions and 159 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lotti/features/calendar/state/day_view_controller.dart';
import 'package:lotti/features/categories/ui/widgets/categories_type_card.dart';
import 'package:lotti/l10n/app_localizations_context.dart';
import 'package:lotti/themes/theme.dart';
import 'package:lotti/utils/date_utils_extension.dart';
import 'package:lotti/widgets/charts/utils.dart';
import 'package:lotti/widgets/settings/categories/categories_type_card.dart';

class TimeByCategoryChartLegend extends ConsumerWidget {
const TimeByCategoryChartLegend({
Expand Down
37 changes: 37 additions & 0 deletions lib/features/categories/repository/categories_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lotti/classes/entity_definitions.dart';
import 'package:lotti/get_it.dart';
import 'package:lotti/logic/persistence_logic.dart';
import 'package:uuid/uuid.dart';

final categoriesRepositoryProvider = Provider<CategoriesRepository>((ref) {
return CategoriesRepository(getIt<PersistenceLogic>());
});

class CategoriesRepository {
CategoriesRepository(this._persistenceLogic);

final PersistenceLogic _persistenceLogic;
final _uuid = const Uuid();

Future<CategoryDefinition> createCategory({
required String name,
required String color,
}) async {
final now = DateTime.now();

final category = CategoryDefinition(
id: _uuid.v4(),
name: name,
color: color,
createdAt: now,
updatedAt: now,
vectorClock: null,
private: false,
active: true, // The persistence logic will handle the vector clock
);

await _persistenceLogic.upsertEntityDefinition(category);
return category;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,3 @@ class CategoryColorIcon extends StatelessWidget {
);
}
}

class HabitCompletionColorIcon extends StatelessWidget {
const HabitCompletionColorIcon(
this.habitId, {
this.size = 50.0,
super.key,
});

final String? habitId;
final double size;

@override
Widget build(BuildContext context) {
final habitDefinition = getIt<EntitiesCacheService>().getHabitById(habitId);

return CategoryColorIcon(
habitDefinition?.categoryId,
size: size,
);
}
}
107 changes: 107 additions & 0 deletions lib/features/categories/ui/widgets/category_create_modal.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lotti/classes/entity_definitions.dart';
import 'package:lotti/features/categories/repository/categories_repository.dart';
import 'package:lotti/l10n/app_localizations_context.dart';
import 'package:lotti/utils/color.dart';

class CategoryCreateModal extends ConsumerStatefulWidget {
const CategoryCreateModal({
required this.onCategoryCreated,
required this.initialName,
this.initialColor,
super.key,
});

final void Function(CategoryDefinition) onCategoryCreated;
final String initialName;
final String? initialColor;

@override
ConsumerState<CategoryCreateModal> createState() =>
_CategoryCreateModalState();
}

class _CategoryCreateModalState extends ConsumerState<CategoryCreateModal> {
late TextEditingController _nameController;
late Color _pickerColor;

@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_pickerColor = widget.initialColor != null
? colorFromCssHex(widget.initialColor)
: Colors.red;
}

@override
void dispose() {
_nameController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: context.messages.habitCategoryLabel,
),
),
const SizedBox(height: 16),
Text(
context.messages.colorLabel,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
ColorPicker(
pickerColor: _pickerColor,
enableAlpha: false,
labelTypes: const [],
onColorChanged: (color) {
setState(() {
_pickerColor = color;
});
},
pickerAreaBorderRadius: BorderRadius.circular(10),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.messages.cancelButton),
),
const SizedBox(width: 8),
TextButton(
onPressed: () async {
final repository = ref.read(categoriesRepositoryProvider);
final category = await repository.createCategory(
name: _nameController.text,
color: colorToCssHex(_pickerColor),
);
widget.onCategoryCreated(category);
if (context.mounted) {
Navigator.pop(context);
}
},
child: Text(context.messages.saveLabel),
),
],
),
],
),
);
}
}
182 changes: 182 additions & 0 deletions lib/features/categories/ui/widgets/category_field.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lotti/classes/entity_definitions.dart';
import 'package:lotti/features/categories/ui/widgets/categories_type_card.dart';
import 'package:lotti/features/categories/ui/widgets/category_create_modal.dart';
import 'package:lotti/get_it.dart';
import 'package:lotti/l10n/app_localizations_context.dart';
import 'package:lotti/services/entities_cache_service.dart';
import 'package:lotti/themes/theme.dart';
import 'package:lotti/utils/color.dart';
import 'package:lotti/utils/modals.dart';
import 'package:lotti/widgets/settings/settings_card.dart';

typedef CategoryCallback = void Function(CategoryDefinition?);

class CategoryField extends StatelessWidget {
const CategoryField({
required this.categoryId,
required this.onSave,
super.key,
});

final String? categoryId;
final CategoryCallback onSave;

@override
Widget build(BuildContext context) {
final category = getIt<EntitiesCacheService>().getCategoryById(categoryId);
final controller = TextEditingController()..text = category?.name ?? '';

void onTap() {
ModalUtils.showSinglePageModal(
context: context,
title: context.messages.habitCategoryLabel,
builder: (BuildContext _) {
return _CategorySelectionContent(
onCategorySelected: (category) {
onSave(category);
Navigator.pop(context);
},
);
},
);
}

final categoryUndefined = category == null;
final style = context.textTheme.titleMedium;

return TextField(
onTap: onTap,
readOnly: true,
focusNode: FocusNode(),
controller: controller,
decoration: inputDecoration(
labelText: categoryUndefined ? '' : context.messages.habitCategoryLabel,
semanticsLabel: 'Select category',
themeData: Theme.of(context),
).copyWith(
icon: ColorIcon(
category != null
? colorFromCssHex(category.color)
: context.colorScheme.outline.withAlpha(51),
),
suffixIcon: categoryUndefined
? null
: GestureDetector(
child: Icon(
Icons.close_rounded,
color: style?.color,
),
onTap: () {
controller.clear();
onSave(null);
},
),
hintText: context.messages.habitCategoryHint,
hintStyle: style?.copyWith(
color: context.colorScheme.outline.withAlpha(127),
),
border: InputBorder.none,
),
style: style,
);
}
}

class _CategorySelectionContent extends ConsumerStatefulWidget {
const _CategorySelectionContent({
required this.onCategorySelected,
});

final CategoryCallback onCategorySelected;

@override
ConsumerState<_CategorySelectionContent> createState() =>
_CategorySelectionContentState();
}

class _CategorySelectionContentState
extends ConsumerState<_CategorySelectionContent> {
final searchController = TextEditingController();
String searchQuery = '';

@override
void dispose() {
searchController.dispose();
super.dispose();
}

Future<void> _showColorPicker(String categoryName) async {
if (!mounted) return;

await ModalUtils.showSinglePageModal(
context: context,
title: context.messages.createCategoryTitle,
builder: (BuildContext context) {
return CategoryCreateModal(
initialName: categoryName,
onCategoryCreated: (category) {
widget.onCategorySelected(category);
},
);
},
);
}

@override
Widget build(BuildContext context) {
final categories = getIt<EntitiesCacheService>().sortedCategories;
final filteredCategories = categories
.where(
(category) =>
category.name.toLowerCase().contains(searchQuery.toLowerCase()),
)
.toList();

return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: context.messages.categorySearchPlaceholder,
prefixIcon: const Icon(Icons.search),
),
onChanged: (value) {
setState(() {
searchQuery = value;
});
},
onSubmitted: (value) {
if (filteredCategories.isEmpty && value.isNotEmpty) {
_showColorPicker(value);
}
},
),
),
if (filteredCategories.isEmpty && searchQuery.isNotEmpty)
SettingsCard(
onTap: () => _showColorPicker(searchQuery),
title: searchQuery,
leading: Icon(
Icons.add_circle_outline,
color: context.colorScheme.primary,
),
)
else
...filteredCategories.map(
(category) => SettingsCard(
onTap: () => widget.onCategorySelected(category),
title: category.name,
leading: ColorIcon(
colorFromCssHex(category.color),
),
),
),
],
);
}
}
Loading

0 comments on commit 9a246cd

Please sign in to comment.