Add Share Inbox flow for unresolved WhatsApp imports

This commit is contained in:
Rijad Zuzo
2026-02-19 00:26:18 +01:00
parent d2205bd3d9
commit d70edb4c4c
9 changed files with 1278 additions and 113 deletions
@@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
/// Review unresolved shared messages and map them to a person profile.
class ShareInboxView extends ConsumerStatefulWidget {
const ShareInboxView({super.key});
@override
ConsumerState<ShareInboxView> createState() => _ShareInboxViewState();
}
class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
bool _submitting = false;
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> state = ref.watch(localRepositoryProvider);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 14 : 20,
compact ? 16 : 28,
compact ? 16 : 20,
),
child: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return Center(
child: Text(
'Failed to load Share Inbox.',
style: Theme.of(context).textTheme.titleMedium,
),
);
},
data: (LocalDataState localState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Share Inbox',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Resolve shared messages that could not be matched confidently.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 14),
Expanded(
child: localState.sharedInbox.isEmpty
? _EmptyInbox(compact: compact)
: ListView.separated(
itemCount: localState.sharedInbox.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 10),
itemBuilder: (BuildContext context, int index) {
final SharedInboxEntry entry =
localState.sharedInbox[index];
final List<PersonProfile> options =
_orderedPeopleForEntry(entry, localState);
return _InboxEntryCard(
entry: entry,
compact: compact,
submitting: _submitting,
suggestedMatches: options,
onResolveToExisting: () =>
_resolveToExisting(entry, options),
onCreateProfile: () => _createProfile(entry),
onDismiss: () => _dismiss(entry.id),
);
},
),
),
],
);
},
),
);
},
);
}
List<PersonProfile> _orderedPeopleForEntry(
SharedInboxEntry entry,
LocalDataState state,
) {
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in state.people) person.id: person,
};
final List<PersonProfile> candidates = <PersonProfile>[];
final Set<String> usedIds = <String>{};
for (final String id in entry.candidateProfileIds) {
final PersonProfile? person = peopleById[id];
if (person != null) {
candidates.add(person);
usedIds.add(person.id);
}
}
final List<PersonProfile> others = state.people
.where((PersonProfile person) => !usedIds.contains(person.id))
.toList(growable: false);
return <PersonProfile>[...candidates, ...others];
}
Future<void> _resolveToExisting(
SharedInboxEntry entry,
List<PersonProfile> options,
) async {
if (_submitting || options.isEmpty) {
return;
}
final String? selectedId = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (BuildContext context) {
return SafeArea(
child: ListView(
children: options
.map((PersonProfile person) {
final bool suggested = entry.candidateProfileIds.contains(
person.id,
);
return ListTile(
title: Text(person.name),
subtitle: Text(
suggested
? '${person.relationship} · Suggested'
: person.relationship,
),
leading: CircleAvatar(child: Text(_initials(person.name))),
onTap: () => Navigator.of(context).pop(person.id),
);
})
.toList(growable: false),
),
);
},
);
if (selectedId == null || !mounted) {
return;
}
await _runRepositoryAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.resolveSharedInboxToExistingProfile(
inboxEntryId: entry.id,
profileId: selectedId,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Imported to ${result.profileName ?? 'selected profile'}.',
),
),
);
});
}
Future<void> _createProfile(SharedInboxEntry entry) async {
if (_submitting) {
return;
}
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
context: context,
builder: (BuildContext context) => _CreateProfileDialog(
initialName:
entry.sourceDisplayName ??
entry.sourceUserId ??
entry.sourceThreadId ??
'',
),
);
if (draft == null || !mounted) {
return;
}
await _runRepositoryAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.resolveSharedInboxByCreatingProfile(
inboxEntryId: entry.id,
name: draft.name,
relationship: draft.relationship,
location: draft.location,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Created and imported to ${result.profileName ?? 'profile'}.',
),
),
);
});
}
Future<void> _dismiss(String inboxEntryId) async {
if (_submitting) {
return;
}
await _runRepositoryAction(() async {
await ref
.read(localRepositoryProvider.notifier)
.dismissSharedInboxEntry(inboxEntryId);
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Inbox item dismissed.')));
});
}
Future<void> _runRepositoryAction(Future<void> Function() action) async {
setState(() {
_submitting = true;
});
try {
await action();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Action failed. Please try again.')),
);
}
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
String _initials(String name) {
final List<String> parts = name
.split(RegExp(r'\s+'))
.where((String value) => value.trim().isNotEmpty)
.toList(growable: false);
if (parts.isEmpty) {
return '?';
}
if (parts.length == 1) {
return parts.first.substring(0, 1).toUpperCase();
}
return '${parts.first.substring(0, 1).toUpperCase()}${parts[1].substring(0, 1).toUpperCase()}';
}
}
class _InboxEntryCard extends StatelessWidget {
const _InboxEntryCard({
required this.entry,
required this.compact,
required this.submitting,
required this.suggestedMatches,
required this.onResolveToExisting,
required this.onCreateProfile,
required this.onDismiss,
});
final SharedInboxEntry entry;
final bool compact;
final bool submitting;
final List<PersonProfile> suggestedMatches;
final VoidCallback onResolveToExisting;
final VoidCallback onCreateProfile;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final String sender =
entry.sourceDisplayName ?? entry.sourceUserId ?? 'Unknown sender';
final bool hasCandidates = suggestedMatches.isNotEmpty;
final String reasonLabel = switch (entry.reason) {
SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match',
SharedInboxReason.missingIdentity => 'Missing identity',
};
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
sender,
style: Theme.of(context).textTheme.titleLarge,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: const Color(0xFFEFF5FA),
),
child: Text(
reasonLabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
),
],
),
const SizedBox(height: 8),
Text(entry.messageText, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 10),
Text(
'Shared ${_formatDateTime(entry.sharedAt)}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: submitting || !hasCandidates
? null
: onResolveToExisting,
icon: const Icon(Icons.person_search_rounded),
label: Text(hasCandidates ? 'Match Existing' : 'No Matches'),
),
OutlinedButton.icon(
onPressed: submitting ? null : onCreateProfile,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Create Profile'),
),
TextButton.icon(
onPressed: submitting ? null : onDismiss,
icon: const Icon(Icons.close_rounded),
label: Text(compact ? 'Dismiss' : 'Dismiss Item'),
),
],
),
],
),
);
}
}
class _EmptyInbox extends StatelessWidget {
const _EmptyInbox({required this.compact});
final bool compact;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: SizedBox(
width: double.infinity,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: compact ? 18 : 30,
horizontal: compact ? 6 : 14,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(
Icons.inbox_rounded,
size: 40,
color: AppTheme.primary,
),
const SizedBox(height: 10),
Text(
'No unresolved shares.',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
'Incoming WhatsApp shares that need manual profile mapping will appear here.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}
class _CreateProfileDraft {
const _CreateProfileDraft({
required this.name,
required this.relationship,
this.location,
});
final String name;
final String relationship;
final String? location;
}
class _CreateProfileDialog extends StatefulWidget {
const _CreateProfileDialog({required this.initialName});
final String initialName;
@override
State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
}
class _CreateProfileDialogState extends State<_CreateProfileDialog> {
late final TextEditingController _nameController;
late final TextEditingController _relationshipController;
late final TextEditingController _locationController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_relationshipController = TextEditingController(text: 'WhatsApp Contact');
_locationController = TextEditingController();
}
@override
void dispose() {
_nameController.dispose();
_relationshipController.dispose();
_locationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Create Profile'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 8),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(labelText: 'Relationship'),
),
const SizedBox(height: 8),
TextField(
controller: _locationController,
decoration: const InputDecoration(
labelText: 'Location (optional)',
),
),
],
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(
_CreateProfileDraft(
name: _nameController.text,
relationship: _relationshipController.text,
location: _locationController.text,
),
);
},
child: const Text('Create'),
),
],
);
}
}
String _formatDateTime(DateTime value) {
String twoDigits(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ${twoDigits(value.hour)}:${twoDigits(value.minute)}';
}