import 'package:easy_localization/easy_localization.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:obtainium/components/generated_form_renderer.dart' show generateRandomLightColor; import 'package:obtainium/components/ui_widgets.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:provider/provider.dart'; /// A small curated palette of pleasant category colours for quick picking. const List kCategoryPalette = [ Color(0xFFEF5350), Color(0xFFFF7043), Color(0xFFFFA726), Color(0xFFFFCA28), Color(0xFF9CCC65), Color(0xFF66BB6A), Color(0xFF26A69A), Color(0xFF26C6DA), Color(0xFF29B6F6), Color(0xFF42A5F5), Color(0xFF5C6BC0), Color(0xFF7E57C2), Color(0xFFAB47BC), Color(0xFFEC407A), Color(0xFF8D6E63), Color(0xFF78909C), ]; /// The outcome of editing a category via [showCategoryEditor]. class CategoryEditResult { /// The resulting category name; null if the category was deleted. final String? name; /// The name being edited (null when creating); lets callers migrate any /// selection that referenced the old name. final String? previous; const CategoryEditResult({this.name, this.previous}); } /// Opens the bottom-sheet editor to create (when [existingName] is null) or /// edit/recolor/rename/delete a category. Persists changes to the global /// category registry (migrating app references on rename, pruning on delete) /// and returns what happened, or null if cancelled. Future showCategoryEditor( BuildContext context, { String? existingName, }) { final registry = context.read().categories; final initialColor = existingName != null && registry[existingName] != null ? Color(registry[existingName]!) : generateRandomLightColor(); return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (ctx) => _CategoryEditorSheet( existingName: existingName, initialColor: initialColor, ), ); } class _CategoryEditorSheet extends StatefulWidget { final String? existingName; final Color initialColor; const _CategoryEditorSheet({this.existingName, required this.initialColor}); @override State<_CategoryEditorSheet> createState() => _CategoryEditorSheetState(); } class _CategoryEditorSheetState extends State<_CategoryEditorSheet> { late final TextEditingController _nameCtrl = TextEditingController( text: widget.existingName ?? '', ); late final ValueNotifier _nameNotifier = ValueNotifier( widget.existingName ?? '', ); late Color _color = widget.initialColor; bool get _isEditing => widget.existingName != null; @override void dispose() { _nameCtrl.dispose(); _nameNotifier.dispose(); super.dispose(); } Future _save() async { final name = _nameCtrl.text.trim(); if (name.isEmpty) return; final settingsProvider = context.read(); final appsProvider = context.read(); final cats = Map.from(settingsProvider.categories); final prev = widget.existingName; // Creating a category whose name already exists must not overwrite the // existing one's color (matches main, which no-ops on duplicates). if (prev == null && cats.containsKey(name)) { if (context.mounted) { showMessage(tr('categoryAlreadyExists'), context); } return; } if (prev != null && prev != name) { cats.remove(prev); // Migrate apps that referenced the old name so the rename is preserved. final changed = []; for (final aim in appsProvider.apps.values) { if (aim.app.categories.contains(prev)) { aim.app = aim.app.copyWith( categories: aim.app.categories .map((c) => c == prev ? name : c) .toList(), ); changed.add(aim.app); } } if (changed.isNotEmpty) await appsProvider.saveApps(changed); } cats[name] = _color.toARGB32(); settingsProvider.setCategories(cats, appsProvider: appsProvider); if (context.mounted) { // ignore: use_build_context_synchronously Navigator.of(context).pop(CategoryEditResult(name: name, previous: prev)); } } Future _delete() async { final appsProvider = context.read(); final settingsProvider = context.read(); final confirmed = await showConfirmDialog( context, title: tr('deleteCategoriesQuestion'), content: Text(tr('categoryDeleteWarning')), autofocusConfirm: context.read().isTV, ); if (!confirmed) return; final cats = Map.from(settingsProvider.categories) ..remove(widget.existingName); settingsProvider.setCategories(cats, appsProvider: appsProvider); if (mounted) { Navigator.of( context, ).pop(CategoryEditResult(name: null, previous: widget.existingName)); } } Future _pickCustomColor() async { var picked = _color; final ok = await ColorPicker( color: _color, onColorChanged: (c) => picked = c, actionButtons: const ColorPickerActionButtons( okButton: true, closeButton: true, dialogActionButtons: false, ), pickersEnabled: const { ColorPickerType.both: false, ColorPickerType.primary: false, ColorPickerType.accent: false, ColorPickerType.bw: false, ColorPickerType.custom: false, ColorPickerType.wheel: true, }, title: Text( tr('selectX', args: [lowerCaseUnlessLang(tr('colour'), 'de')]), style: Theme.of(context).textTheme.titleLarge, ), wheelDiameter: 192, wheelSquareBorderRadius: 32, borderRadius: 24, enableShadesSelection: false, ).showPickerDialog(context); if (ok && context.mounted) setState(() => _color = picked); } Widget _swatch({ required Color color, required bool selected, required VoidCallback onTap, Widget? icon, }) { final cs = Theme.of(context).colorScheme; return Semantics( button: true, selected: selected, child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: Border.all( color: selected ? cs.onSurface : cs.outlineVariant, width: selected ? 3 : 1, ), ), child: icon == null ? (selected ? Icon( Icons.check, size: 20, color: ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black, ) : null) : Center(child: icon), ), ), ); } @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Padding( padding: EdgeInsets.fromLTRB( 20, 4, 20, 20 + MediaQuery.of(context).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( _isEditing ? tr('editCategory') : tr('newCategory'), style: textTheme.titleLarge, ), const SizedBox(height: 16), ConnectedCard( child: TextField( controller: _nameCtrl, autofocus: !_isEditing, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration(labelText: tr('categoryName')), onChanged: (value) => _nameNotifier.value = value, onSubmitted: (_) { if (_nameCtrl.text.trim().isNotEmpty) _save(); }, ), ), const SizedBox(height: 20), Text(tr('colour'), style: textTheme.titleSmall), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final c in kCategoryPalette) _swatch( color: c, selected: c.toARGB32() == _color.toARGB32(), onTap: () => setState(() => _color = c), ), _swatch( color: Theme.of(context).colorScheme.surfaceContainerHighest, selected: false, onTap: () => setState(() => _color = generateRandomLightColor()), icon: Tooltip( message: tr('randomColour'), child: const Icon(Icons.casino_outlined, size: 20), ), ), _swatch( color: Theme.of(context).colorScheme.surfaceContainerHighest, selected: false, onTap: _pickCustomColor, icon: Tooltip( message: tr('custom'), child: const Icon(Icons.colorize_outlined, size: 20), ), ), ], ), const SizedBox(height: 24), Row( children: [ if (_isEditing) TextButton.icon( onPressed: _delete, icon: const Icon(Icons.delete_outline), label: Text(tr('remove')), style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), ), const Spacer(), TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(tr('cancel')), ), const SizedBox(width: 8), ValueListenableBuilder( valueListenable: _nameNotifier, builder: (context, value, _) { final canSave = value.trim().isNotEmpty; return FilledButton( onPressed: canSave ? _save : null, child: Text(tr('continue')), ); }, ), ], ), ], ), ); } } /// A multi- (or single-) select field over the global category registry. /// /// Tapping a chip toggles its selection; long-pressing opens the editor /// (recolor/rename/delete); the trailing "+ New" chip creates a category (when /// [allowCreate]). Selection is reported via [onChanged] but never persisted — /// hosts decide when to apply it, keeping it consistent with other form inputs. class CategorySelector extends StatefulWidget { final Set selected; final ValueChanged> onChanged; final bool singleSelect; final bool allowCreate; final WrapAlignment alignment; const CategorySelector({ super.key, required this.selected, required this.onChanged, this.singleSelect = false, this.allowCreate = true, this.alignment = WrapAlignment.start, }); @override State createState() => _CategorySelectorState(); } class _CategorySelectorState extends State { late Set _selected = {...widget.selected}; @override void didUpdateWidget(CategorySelector oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.selected.length != widget.selected.length || !setEquals(oldWidget.selected, widget.selected)) { _selected = {...widget.selected}; } } void _emit() => widget.onChanged({..._selected}); void _toggle(String name, bool value) { setState(() { if (widget.singleSelect) { _selected = value ? {name} : {}; } else if (value) { _selected.add(name); } else { _selected.remove(name); } }); _emit(); } Future _create() async { final result = await showCategoryEditor(context); if (result?.name == null) return; if (!context.mounted) return; setState(() { if (widget.singleSelect) { _selected = {result!.name!}; } else { _selected.add(result!.name!); } }); _emit(); } Future _edit(String name) async { final result = await showCategoryEditor(context, existingName: name); if (result == null) return; if (!context.mounted) return; final wasSelected = _selected.contains(name); setState(() { _selected.remove(name); if (result.name != null && wasSelected) _selected.add(result.name!); }); if (wasSelected) _emit(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final categories = context.select>( (p) => p.categories, ); final names = categories.keys.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); final prunedSelected = _selected.where(categories.containsKey).toSet(); if (prunedSelected.length != _selected.length) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { setState(() { _selected = prunedSelected; }); _emit(); } }); } if (names.isEmpty && !widget.allowCreate) { return Text( tr('noCategories'), style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant), ); } if (names.isEmpty && widget.allowCreate) { return Wrap( alignment: widget.alignment, spacing: 8, runSpacing: 8, children: [ ActionChip( avatar: const Icon(Icons.add, size: 18), label: Text(tr('newCategory')), onPressed: _create, ), ], ); } return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Wrap( alignment: widget.alignment, spacing: 8, runSpacing: 8, children: [ for (final name in names) Tooltip( message: tr('editCategory'), child: Semantics( onLongPress: () => _edit(name), child: GestureDetector( onLongPress: () => _edit(name), child: FilterChip( avatar: CircleAvatar( backgroundColor: Color(categories[name] ?? 0xFFCCCCCC), radius: 7, ), label: Text(name), selected: _selected.contains(name), onSelected: (v) => _toggle(name, v), selectedColor: Color( categories[name] ?? 0xFFCCCCCC, ).withValues(alpha: 0.22), showCheckmark: true, ), ), ), ), ], ), ), if (widget.allowCreate) const SizedBox(width: 8), if (widget.allowCreate) ActionChip( avatar: const Icon(Icons.add, size: 18), label: Text(tr('newCategory')), onPressed: _create, ), ], ); } } /// Manage-only view of the category registry (used in Settings): every category /// is an editable chip (tap to recolor/rename/delete) plus a "+ New" action. class CategoryManager extends StatelessWidget { const CategoryManager({super.key}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final categories = context.watch().categories; final names = categories.keys.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); if (names.isEmpty) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( tr('noCategories'), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: cs.onSurfaceVariant), ), const Spacer(), ActionChip( avatar: const Icon(Icons.add, size: 18), label: Text(tr('newCategory')), onPressed: () => showCategoryEditor(context), ), ], ); } return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final name in names) ActionChip( avatar: CircleAvatar( backgroundColor: Color(categories[name]!), radius: 7, ), label: Text(name), onPressed: () => showCategoryEditor(context, existingName: name), ), ], ), ), const SizedBox(width: 8), ActionChip( avatar: const Icon(Icons.add, size: 18), label: Text(tr('newCategory')), onPressed: () => showCategoryEditor(context), ), ], ); } }