f655adfbea
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
1384 lines
45 KiB
Dart
1384 lines
45 KiB
Dart
part of 'people_view.dart';
|
|
|
|
Future<PersonFact?> _showPersonFactEditor(
|
|
BuildContext context, {
|
|
required PersonFact fact,
|
|
}) {
|
|
return showDialog<PersonFact>(
|
|
context: context,
|
|
builder: (BuildContext context) => _PersonFactEditorDialog(fact: fact),
|
|
);
|
|
}
|
|
|
|
Future<PersonImportantDate?> _showImportantDateEditor(
|
|
BuildContext context, {
|
|
required PersonImportantDate value,
|
|
}) {
|
|
return showDialog<PersonImportantDate>(
|
|
context: context,
|
|
builder: (BuildContext context) => _ImportantDateEditorDialog(value: value),
|
|
);
|
|
}
|
|
|
|
class _PersonFactEditorDialog extends StatefulWidget {
|
|
const _PersonFactEditorDialog({required this.fact});
|
|
|
|
final PersonFact fact;
|
|
|
|
@override
|
|
State<_PersonFactEditorDialog> createState() =>
|
|
_PersonFactEditorDialogState();
|
|
}
|
|
|
|
class _PersonFactEditorDialogState extends State<_PersonFactEditorDialog> {
|
|
late CapturedFactType _type;
|
|
late final TextEditingController _labelController;
|
|
late final TextEditingController _textController;
|
|
bool _isSensitive = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_type = widget.fact.type;
|
|
_labelController = TextEditingController(text: widget.fact.label ?? '');
|
|
_textController = TextEditingController(text: widget.fact.text);
|
|
_isSensitive = widget.fact.isSensitive;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_labelController.dispose();
|
|
_textController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Edit Captured Fact'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 460),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
DropdownButtonFormField<CapturedFactType>(
|
|
initialValue: _type,
|
|
decoration: const InputDecoration(labelText: 'Type'),
|
|
items: CapturedFactType.values
|
|
.where(
|
|
(CapturedFactType type) =>
|
|
type != CapturedFactType.importantDate,
|
|
)
|
|
.map(
|
|
(CapturedFactType type) =>
|
|
DropdownMenuItem<CapturedFactType>(
|
|
value: type,
|
|
child: Text(_factTypeLabel(type)),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: (CapturedFactType? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_type = value;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _labelController,
|
|
decoration: const InputDecoration(labelText: 'Label'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _textController,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(labelText: 'Text'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Sensitive'),
|
|
value: _isSensitive,
|
|
onChanged: (bool value) {
|
|
setState(() {
|
|
_isSensitive = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(
|
|
widget.fact.copyWith(
|
|
type: _type,
|
|
label: _labelController.text.trim().isEmpty
|
|
? null
|
|
: _labelController.text.trim(),
|
|
text: _textController.text.trim(),
|
|
isSensitive: _isSensitive,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImportantDateEditorDialog extends StatefulWidget {
|
|
const _ImportantDateEditorDialog({required this.value});
|
|
|
|
final PersonImportantDate value;
|
|
|
|
@override
|
|
State<_ImportantDateEditorDialog> createState() =>
|
|
_ImportantDateEditorDialogState();
|
|
}
|
|
|
|
class _ImportantDateEditorDialogState
|
|
extends State<_ImportantDateEditorDialog> {
|
|
late final TextEditingController _labelController;
|
|
late final TextEditingController _classificationController;
|
|
late DateTime _date;
|
|
bool _isSensitive = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_labelController = TextEditingController(text: widget.value.label);
|
|
_classificationController = TextEditingController(
|
|
text: widget.value.classification,
|
|
);
|
|
_date = widget.value.date;
|
|
_isSensitive = widget.value.isSensitive;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_labelController.dispose();
|
|
_classificationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Edit Important Date'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 460),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _labelController,
|
|
decoration: const InputDecoration(labelText: 'Label'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _classificationController,
|
|
decoration: const InputDecoration(labelText: 'Classification'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton.icon(
|
|
onPressed: _pickDate,
|
|
icon: const Icon(Icons.event_outlined),
|
|
label: Text(_dateTimeLabel(_date)),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Sensitive'),
|
|
value: _isSensitive,
|
|
onChanged: (bool value) {
|
|
setState(() {
|
|
_isSensitive = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(
|
|
widget.value.copyWith(
|
|
label: _labelController.text.trim(),
|
|
classification: _classificationController.text.trim(),
|
|
date: _date,
|
|
isSensitive: _isSensitive,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _pickDate() async {
|
|
final DateTime? picked = await showDatePicker(
|
|
context: context,
|
|
firstDate: DateTime(_date.year - 5),
|
|
lastDate: DateTime(_date.year + 10),
|
|
initialDate: _date,
|
|
);
|
|
if (picked == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_date = picked;
|
|
});
|
|
}
|
|
}
|
|
|
|
class _PersonEditorDialog extends StatelessWidget {
|
|
const _PersonEditorDialog({
|
|
required this.title,
|
|
required this.existingPeople,
|
|
this.initial,
|
|
this.editingPersonId,
|
|
});
|
|
|
|
final String title;
|
|
final List<PersonProfile> existingPeople;
|
|
final _PersonDraft? initial;
|
|
final String? editingPersonId;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 720),
|
|
child: _PersonEditorPanel(
|
|
title: title,
|
|
initial: initial,
|
|
existingPeople: existingPeople,
|
|
editingPersonId: editingPersonId,
|
|
compact: false,
|
|
sheetStyle: false,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuickTextCaptureDialog extends StatefulWidget {
|
|
const _QuickTextCaptureDialog({required this.title, required this.hintText});
|
|
|
|
final String title;
|
|
final String hintText;
|
|
|
|
@override
|
|
State<_QuickTextCaptureDialog> createState() =>
|
|
_QuickTextCaptureDialogState();
|
|
}
|
|
|
|
class _QuickTextCaptureDialogState extends State<_QuickTextCaptureDialog> {
|
|
late final TextEditingController _controller;
|
|
bool _attemptedSubmit = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String value = _controller.text.trim();
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: TextField(
|
|
controller: _controller,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
autofocus: true,
|
|
onChanged: (_) {
|
|
if (_attemptedSubmit) {
|
|
setState(() {});
|
|
}
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: widget.hintText,
|
|
errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null,
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String submitted = _controller.text.trim();
|
|
if (submitted.isEmpty) {
|
|
setState(() {
|
|
_attemptedSubmit = true;
|
|
});
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(submitted);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonEditorBottomSheet extends StatelessWidget {
|
|
const _PersonEditorBottomSheet({
|
|
required this.title,
|
|
required this.existingPeople,
|
|
this.initial,
|
|
this.editingPersonId,
|
|
});
|
|
|
|
final String title;
|
|
final List<PersonProfile> existingPeople;
|
|
final _PersonDraft? initial;
|
|
final String? editingPersonId;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
bottom: MediaQuery.viewInsetsOf(context).bottom + 8,
|
|
),
|
|
child: _PersonEditorPanel(
|
|
title: title,
|
|
initial: initial,
|
|
existingPeople: existingPeople,
|
|
editingPersonId: editingPersonId,
|
|
compact: true,
|
|
sheetStyle: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonEditorPanel extends StatefulWidget {
|
|
const _PersonEditorPanel({
|
|
required this.title,
|
|
required this.compact,
|
|
required this.sheetStyle,
|
|
required this.existingPeople,
|
|
this.initial,
|
|
this.editingPersonId,
|
|
});
|
|
|
|
final String title;
|
|
final bool compact;
|
|
final bool sheetStyle;
|
|
final List<PersonProfile> existingPeople;
|
|
final _PersonDraft? initial;
|
|
final String? editingPersonId;
|
|
|
|
@override
|
|
State<_PersonEditorPanel> createState() => _PersonEditorPanelState();
|
|
}
|
|
|
|
class _PersonEditorPanelState extends State<_PersonEditorPanel> {
|
|
late final TextEditingController _nameController;
|
|
late final TextEditingController _relationshipController;
|
|
late final TextEditingController _locationController;
|
|
late final TextEditingController _aliasesController;
|
|
late final TextEditingController _tagsController;
|
|
late final TextEditingController _notesController;
|
|
bool _attemptedSubmit = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nameController = TextEditingController(text: widget.initial?.name ?? '')
|
|
..addListener(_handlePreviewChanged);
|
|
_relationshipController = TextEditingController(
|
|
text: widget.initial?.relationship ?? '',
|
|
)..addListener(_handlePreviewChanged);
|
|
_locationController = TextEditingController(
|
|
text: widget.initial?.location ?? '',
|
|
)..addListener(_handlePreviewChanged);
|
|
_aliasesController = TextEditingController(
|
|
text: widget.initial?.aliases.join(', ') ?? '',
|
|
)..addListener(_handlePreviewChanged);
|
|
_tagsController = TextEditingController(
|
|
text: widget.initial?.tags.join(', ') ?? '',
|
|
)..addListener(_handlePreviewChanged);
|
|
_notesController = TextEditingController(text: widget.initial?.notes ?? '');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController
|
|
..removeListener(_handlePreviewChanged)
|
|
..dispose();
|
|
_relationshipController
|
|
..removeListener(_handlePreviewChanged)
|
|
..dispose();
|
|
_locationController
|
|
..removeListener(_handlePreviewChanged)
|
|
..dispose();
|
|
_aliasesController
|
|
..removeListener(_handlePreviewChanged)
|
|
..dispose();
|
|
_tagsController
|
|
..removeListener(_handlePreviewChanged)
|
|
..dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handlePreviewChanged() {
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String name = _nameController.text.trim();
|
|
final String relationship = _relationshipController.text.trim();
|
|
final bool nameMissing = _attemptedSubmit && name.isEmpty;
|
|
final bool relationshipMissing = _attemptedSubmit && relationship.isEmpty;
|
|
final String? location = _locationController.text.trim().isEmpty
|
|
? null
|
|
: _locationController.text.trim();
|
|
final List<String> tags = _tagsController.text
|
|
.split(',')
|
|
.map((String tag) => tag.trim())
|
|
.where((String tag) => tag.isNotEmpty)
|
|
.toList(growable: false);
|
|
final List<PersonProfile> duplicateMatches = _findDuplicateNameMatches(
|
|
name: name,
|
|
people: widget.existingPeople,
|
|
excludePersonId: widget.editingPersonId,
|
|
);
|
|
final bool hasDuplicateWarning = duplicateMatches.isNotEmpty;
|
|
|
|
final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22);
|
|
final double maxHeight = widget.compact ? 720 : 760;
|
|
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxHeight: maxHeight,
|
|
minHeight: widget.compact ? 360 : 420,
|
|
),
|
|
decoration: BoxDecoration(
|
|
borderRadius: radius,
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[Color(0xFFF7FBFD), Color(0xFFEAF3F9)],
|
|
),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.95)),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.08),
|
|
blurRadius: 26,
|
|
offset: const Offset(0, 14),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: radius,
|
|
child: Column(
|
|
children: <Widget>[
|
|
if (widget.sheetStyle)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Container(
|
|
width: 42,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFCCD6DD),
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
widget.compact ? 14 : 18,
|
|
widget.compact ? 10 : 16,
|
|
widget.compact ? 14 : 18,
|
|
12,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_PersonEditorHero(
|
|
title: widget.title,
|
|
compact: widget.compact,
|
|
previewName: name,
|
|
previewRelationship: relationship,
|
|
previewLocation: location,
|
|
previewTags: tags,
|
|
),
|
|
if (hasDuplicateWarning) ...<Widget>[
|
|
const SizedBox(height: 10),
|
|
_PersonEditorWarningBanner(matches: duplicateMatches),
|
|
],
|
|
const SizedBox(height: 12),
|
|
if (widget.compact)
|
|
Column(
|
|
children: <Widget>[
|
|
_PersonEditorSectionCard(
|
|
title: 'Identity',
|
|
icon: Icons.person_outline_rounded,
|
|
child: Column(
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _nameController,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Name',
|
|
errorText: nameMissing
|
|
? 'Name is required'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _relationshipController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Relationship',
|
|
errorText: relationshipMissing
|
|
? 'Relationship is required'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _locationController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Location (optional)',
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _aliasesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Aliases (comma separated)',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_PersonEditorSectionCard(
|
|
title: 'Context',
|
|
icon: Icons.auto_awesome_rounded,
|
|
child: Column(
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _tagsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Tags (comma separated)',
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _notesController,
|
|
minLines: 3,
|
|
maxLines: 5,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes',
|
|
hintText:
|
|
'Preferences, boundaries, gift clues, recurring themes...',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: _PersonEditorSectionCard(
|
|
title: 'Identity',
|
|
icon: Icons.person_outline_rounded,
|
|
child: Column(
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _nameController,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Name',
|
|
errorText: nameMissing
|
|
? 'Name is required'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _relationshipController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Relationship',
|
|
errorText: relationshipMissing
|
|
? 'Relationship is required'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _locationController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Location (optional)',
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _aliasesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Aliases (comma separated)',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _PersonEditorSectionCard(
|
|
title: 'Context',
|
|
icon: Icons.auto_awesome_rounded,
|
|
child: Column(
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _tagsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Tags (comma separated)',
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _notesController,
|
|
minLines: 5,
|
|
maxLines: 8,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes',
|
|
hintText:
|
|
'Preferences, boundaries, gift clues, recurring themes...',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding: EdgeInsets.fromLTRB(
|
|
widget.compact ? 14 : 18,
|
|
10,
|
|
widget.compact ? 14 : 18,
|
|
widget.compact ? 14 : 16,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.75),
|
|
border: Border(
|
|
top: BorderSide(color: Colors.white.withValues(alpha: 0.9)),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
'Required: name and relationship',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String submittedName = _nameController.text
|
|
.trim();
|
|
final String submittedRelationship =
|
|
_relationshipController.text.trim();
|
|
if (submittedName.isEmpty ||
|
|
submittedRelationship.isEmpty) {
|
|
setState(() {
|
|
_attemptedSubmit = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final _PersonDraft draft = _PersonDraft(
|
|
name: submittedName,
|
|
relationship: submittedRelationship,
|
|
location: _locationController.text.trim().isEmpty
|
|
? null
|
|
: _locationController.text.trim(),
|
|
aliases: _aliasesController.text
|
|
.split(',')
|
|
.map((String value) => value.trim())
|
|
.where((String value) => value.isNotEmpty)
|
|
.toList(growable: false),
|
|
notes: _notesController.text.trim(),
|
|
tags: _tagsController.text
|
|
.split(',')
|
|
.map((String tag) => tag.trim())
|
|
.where((String tag) => tag.isNotEmpty)
|
|
.toList(growable: false),
|
|
);
|
|
Navigator.of(context).pop(draft);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonEditorHero extends StatelessWidget {
|
|
const _PersonEditorHero({
|
|
required this.title,
|
|
required this.compact,
|
|
required this.previewName,
|
|
required this.previewRelationship,
|
|
required this.previewLocation,
|
|
required this.previewTags,
|
|
});
|
|
|
|
final String title;
|
|
final bool compact;
|
|
final String previewName;
|
|
final String previewRelationship;
|
|
final String? previewLocation;
|
|
final List<String> previewTags;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String displayName = previewName.isEmpty ? 'New person' : previewName;
|
|
final String displayRelationship = previewRelationship.isEmpty
|
|
? 'Relationship type'
|
|
: previewRelationship;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(compact ? 14 : 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: Colors.white.withValues(alpha: 0.72),
|
|
border: Border.all(color: Colors.white),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_AvatarSeed(name: displayName, size: compact ? 50 : 56),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Shape the profile first, then use quick-add from the People workspace.',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
_InlineMetaPill(
|
|
icon: Icons.person_outline_rounded,
|
|
label: displayName,
|
|
),
|
|
_InlineMetaPill(
|
|
icon: Icons.favorite_outline_rounded,
|
|
label: displayRelationship,
|
|
),
|
|
if (previewLocation != null)
|
|
_InlineMetaPill(
|
|
icon: Icons.location_on_outlined,
|
|
label: previewLocation!,
|
|
),
|
|
...previewTags
|
|
.take(compact ? 2 : 4)
|
|
.map(
|
|
(String tag) =>
|
|
_InlineMetaPill(icon: Icons.sell_outlined, label: tag),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonEditorSectionCard extends StatelessWidget {
|
|
const _PersonEditorSectionCard({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.child,
|
|
});
|
|
|
|
final String title;
|
|
final IconData icon;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.78),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.white),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(icon, size: 18, color: AppTheme.primary),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonEditorWarningBanner extends StatelessWidget {
|
|
const _PersonEditorWarningBanner({required this.matches});
|
|
|
|
final List<PersonProfile> matches;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String names = matches
|
|
.take(2)
|
|
.map(
|
|
(PersonProfile person) => '${person.name} (${person.relationship})',
|
|
)
|
|
.join(', ');
|
|
final int extraCount = matches.length - 2;
|
|
final String suffix = extraCount > 0 ? ' +$extraCount more' : '';
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF7E8),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0xFFFFE2A8)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 1),
|
|
child: Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 18,
|
|
color: Color(0xFFA56A00),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Possible duplicate profile name detected. Existing: $names$suffix. You can still save, then merge duplicates from People.',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: const Color(0xFF6F5200)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonDraft {
|
|
const _PersonDraft({
|
|
required this.name,
|
|
required this.relationship,
|
|
required this.notes,
|
|
required this.tags,
|
|
required this.aliases,
|
|
this.location,
|
|
});
|
|
|
|
final String name;
|
|
final String relationship;
|
|
final String notes;
|
|
final List<String> tags;
|
|
final List<String> aliases;
|
|
final String? location;
|
|
}
|
|
|
|
List<PersonProfile> _findDuplicateNameMatches({
|
|
required String name,
|
|
required List<PersonProfile> people,
|
|
String? excludePersonId,
|
|
}) {
|
|
final String normalized = _normalizePersonNameKey(name);
|
|
if (normalized.isEmpty) {
|
|
return const <PersonProfile>[];
|
|
}
|
|
|
|
return people
|
|
.where((PersonProfile person) {
|
|
if (excludePersonId != null && person.id == excludePersonId) {
|
|
return false;
|
|
}
|
|
return _normalizePersonNameKey(person.name) == normalized;
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String _normalizePersonNameKey(String value) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
|
|
.replaceAll(RegExp(r'\s+'), ' ');
|
|
}
|
|
|
|
class _MergePeopleDraft {
|
|
const _MergePeopleDraft({
|
|
required this.sourcePersonId,
|
|
required this.targetPersonId,
|
|
});
|
|
|
|
final String sourcePersonId;
|
|
final String targetPersonId;
|
|
}
|
|
|
|
class _MergePeopleDialog extends StatefulWidget {
|
|
const _MergePeopleDialog({required this.people});
|
|
|
|
final List<PersonProfile> people;
|
|
|
|
@override
|
|
State<_MergePeopleDialog> createState() => _MergePeopleDialogState();
|
|
}
|
|
|
|
class _MergePeopleDialogState extends State<_MergePeopleDialog> {
|
|
late String _sourcePersonId;
|
|
late String _targetPersonId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_targetPersonId = widget.people.first.id;
|
|
_sourcePersonId = widget.people.length > 1
|
|
? widget.people[1].id
|
|
: widget.people.first.id;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Merge Duplicate Profiles'),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Text(
|
|
'Move captures, ideas, reminders, and share links from source into target profile.',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey<String>('target-$_targetPersonId'),
|
|
initialValue: _targetPersonId,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Keep target profile',
|
|
),
|
|
items: widget.people
|
|
.map(
|
|
(PersonProfile person) => DropdownMenuItem<String>(
|
|
value: person.id,
|
|
child: Text('${person.name} · ${person.relationship}'),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: (String? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_targetPersonId = value;
|
|
if (_targetPersonId == _sourcePersonId) {
|
|
_sourcePersonId = widget.people
|
|
.firstWhere(
|
|
(PersonProfile person) =>
|
|
person.id != _targetPersonId,
|
|
orElse: () => widget.people.first,
|
|
)
|
|
.id;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey<String>('source-$_targetPersonId-$_sourcePersonId'),
|
|
initialValue: _sourcePersonId,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Merge and remove source profile',
|
|
),
|
|
items: widget.people
|
|
.where((PersonProfile person) => person.id != _targetPersonId)
|
|
.map(
|
|
(PersonProfile person) => DropdownMenuItem<String>(
|
|
value: person.id,
|
|
child: Text('${person.name} · ${person.relationship}'),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: (String? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_sourcePersonId = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (_sourcePersonId == _targetPersonId) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(
|
|
_MergePeopleDraft(
|
|
sourcePersonId: _sourcePersonId,
|
|
targetPersonId: _targetPersonId,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Merge'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuickIdeaDraft {
|
|
const _QuickIdeaDraft({
|
|
required this.type,
|
|
required this.title,
|
|
required this.details,
|
|
});
|
|
|
|
final IdeaType type;
|
|
final String title;
|
|
final String details;
|
|
}
|
|
|
|
class _QuickIdeaDialog extends StatefulWidget {
|
|
const _QuickIdeaDialog();
|
|
|
|
@override
|
|
State<_QuickIdeaDialog> createState() => _QuickIdeaDialogState();
|
|
}
|
|
|
|
class _QuickIdeaDialogState extends State<_QuickIdeaDialog> {
|
|
IdeaType _type = IdeaType.gift;
|
|
final TextEditingController _titleController = TextEditingController();
|
|
final TextEditingController _detailsController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_detailsController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Add Idea'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
SegmentedButton<IdeaType>(
|
|
segments: const <ButtonSegment<IdeaType>>[
|
|
ButtonSegment<IdeaType>(
|
|
value: IdeaType.gift,
|
|
label: Text('Gift'),
|
|
),
|
|
ButtonSegment<IdeaType>(
|
|
value: IdeaType.event,
|
|
label: Text('Event'),
|
|
),
|
|
],
|
|
selected: <IdeaType>{_type},
|
|
onSelectionChanged: (Set<IdeaType> values) {
|
|
setState(() {
|
|
_type = values.first;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(labelText: 'Title'),
|
|
),
|
|
TextField(
|
|
controller: _detailsController,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(labelText: 'Details'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String title = _titleController.text.trim();
|
|
if (title.isEmpty) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(
|
|
_QuickIdeaDraft(
|
|
type: _type,
|
|
title: title,
|
|
details: _detailsController.text.trim(),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuickReminderDraft {
|
|
const _QuickReminderDraft({
|
|
required this.title,
|
|
required this.cadence,
|
|
required this.nextAt,
|
|
});
|
|
|
|
final String title;
|
|
final ReminderCadence cadence;
|
|
final DateTime nextAt;
|
|
}
|
|
|
|
class _QuickReminderDialog extends StatefulWidget {
|
|
const _QuickReminderDialog();
|
|
|
|
@override
|
|
State<_QuickReminderDialog> createState() => _QuickReminderDialogState();
|
|
}
|
|
|
|
class _QuickReminderDialogState extends State<_QuickReminderDialog> {
|
|
final TextEditingController _titleController = TextEditingController();
|
|
ReminderCadence _cadence = ReminderCadence.weekly;
|
|
DateTime _nextAt = DateTime.now().add(const Duration(days: 1));
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Add Reminder'),
|
|
content: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
TextField(
|
|
controller: _titleController,
|
|
decoration: const InputDecoration(labelText: 'Title'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
DropdownButtonFormField<ReminderCadence>(
|
|
initialValue: _cadence,
|
|
decoration: const InputDecoration(labelText: 'Cadence'),
|
|
items: ReminderCadence.values
|
|
.map(
|
|
(ReminderCadence cadence) =>
|
|
DropdownMenuItem<ReminderCadence>(
|
|
value: cadence,
|
|
child: Text(cadence.name),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: (ReminderCadence? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_cadence = value;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}',
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final DateTime now = DateTime.now();
|
|
final DateTime? date = await showDatePicker(
|
|
context: context,
|
|
firstDate: now,
|
|
lastDate: now.add(const Duration(days: 3650)),
|
|
initialDate: _nextAt,
|
|
);
|
|
if (date == null || !context.mounted) {
|
|
return;
|
|
}
|
|
final TimeOfDay? time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_nextAt),
|
|
);
|
|
if (time == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_nextAt = DateTime(
|
|
date.year,
|
|
date.month,
|
|
date.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
},
|
|
child: const Text('Change'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String title = _titleController.text.trim();
|
|
if (title.isEmpty) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop(
|
|
_QuickReminderDraft(
|
|
title: title,
|
|
cadence: _cadence,
|
|
nextAt: _nextAt,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|