fix: Show recipient activity previews

This commit is contained in:
Janez T
2026-03-23 10:44:20 +01:00
parent 2b42e7dd15
commit 4c048a5a50
13 changed files with 820 additions and 279 deletions

View File

@@ -19,6 +19,8 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
import '../services/region_scope_preferences.dart';
import '../utils/toast_logger.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget {
@@ -546,6 +548,15 @@ class _ContactsTabState extends State<ContactsTab> {
await _exportHashChannelPskBase64(context, channel);
},
),
_ChannelSheetAction(
icon: Icons.language_rounded,
label: l10n.setRegionScope,
onTap: () async {
Navigator.pop(context);
if (!context.mounted) return;
_showRegionScopeForChannel(context, channel);
},
),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: Icons.delete_outline_rounded,
@@ -575,6 +586,38 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
void _showRegionScopeForChannel(BuildContext context, Contact channel) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final l10n = AppLocalizations.of(context)!;
final currentScope = await RegionScopePreferences.getScope(channelIdx);
if (!context.mounted) return;
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _ContactsRegionScopeSheet(
currentScopeName: currentScope?.name,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
if (name == null) {
await RegionScopePreferences.clearScope(channelIdx);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeCleared);
} else {
final key = RegionScopePreferences.deriveRegionKey(name);
await RegionScopePreferences.setScope(channelIdx, name, key);
if (!context.mounted) return;
ToastLogger.success(context, l10n.regionScopeSet(name));
}
},
);
},
);
}
Color _sectionAccentColor(BuildContext context, ContactSection section) {
final colorScheme = Theme.of(context).colorScheme;
switch (section) {
@@ -2394,3 +2437,165 @@ class _MetricChip extends StatelessWidget {
);
}
}
class _ContactsRegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _ContactsRegionScopeSheet({
required this.currentScopeName,
required this.l10n,
required this.onScopeSelected,
});
@override
State<_ContactsRegionScopeSheet> createState() =>
_ContactsRegionScopeSheetState();
}
class _ContactsRegionScopeSheetState
extends State<_ContactsRegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
if (!name.startsWith('#')) name = '#$name';
widget.onScopeSelected(name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = widget.l10n;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.72,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionScope,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.regionScopeWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_ScopeOption(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
onTap: () => widget.onScopeSelected(null),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: l10n.enterRegionName,
isDense: true,
prefixText: '#',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => _submitManualName(),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _submitManualName,
child: const Icon(Icons.check_rounded, size: 20),
),
],
),
],
),
),
),
);
}
}
class _ScopeOption extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ScopeOption({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -37,7 +37,6 @@ import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart';
import '../services/image_preferences.dart';
import '../services/region_scope_preferences.dart';
import '../services/region_discovery_service.dart';
import 'package:image_picker/image_picker.dart';
import '../l10n/app_localizations.dart';
@@ -1763,12 +1762,7 @@ class _MessagesTabState extends State<MessagesTab> {
void _showRegionScopeSheet() {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
final l10n = AppLocalizations.of(context)!;
final repeaters = contactsProvider.contacts
.where((c) => c.isRepeater)
.toList();
showModalBottomSheet(
context: context,
@@ -1777,8 +1771,6 @@ class _MessagesTabState extends State<MessagesTab> {
builder: (sheetContext) {
return _RegionScopeSheet(
currentScopeName: _channelRegionScopeName,
repeaters: repeaters,
connectionProvider: connectionProvider,
l10n: l10n,
onScopeSelected: (String? name) async {
Navigator.of(sheetContext).pop();
@@ -2412,15 +2404,11 @@ class _MessagesTabState extends State<MessagesTab> {
/// Bottom sheet for selecting a region scope for the current channel.
class _RegionScopeSheet extends StatefulWidget {
final String? currentScopeName;
final List<Contact> repeaters;
final ConnectionProvider connectionProvider;
final AppLocalizations l10n;
final ValueChanged<String?> onScopeSelected;
const _RegionScopeSheet({
required this.currentScopeName,
required this.repeaters,
required this.connectionProvider,
required this.l10n,
required this.onScopeSelected,
});
@@ -2431,8 +2419,6 @@ class _RegionScopeSheet extends StatefulWidget {
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
final TextEditingController _nameController = TextEditingController();
List<String> _discoveredRegions = [];
bool _isDiscovering = false;
@override
void dispose() {
@@ -2440,30 +2426,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
super.dispose();
}
Future<void> _discoverRegions() async {
if (widget.repeaters.isEmpty) return;
setState(() => _isDiscovering = true);
final allRegions = <String>{};
for (final repeater in widget.repeaters) {
final regions = await RegionDiscoveryService.discoverFromRepeater(
repeaterPublicKey: repeater.publicKey,
connectionProvider: widget.connectionProvider,
);
allRegions.addAll(regions);
}
if (!mounted) return;
setState(() {
_discoveredRegions = allRegions.toList()..sort();
_isDiscovering = false;
});
if (_discoveredRegions.isEmpty && mounted) {
ToastLogger.info(context, widget.l10n.noRegionsFound);
}
}
void _submitManualName() {
var name = _nameController.text.trim();
if (name.isEmpty) return;
@@ -2504,7 +2466,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
const SizedBox(height: 16),
// "None" option
_RegionOptionTile(
label: l10n.regionScopeNone,
isSelected: widget.currentScopeName == null,
@@ -2512,7 +2473,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
const SizedBox(height: 8),
// Manual entry
Row(
children: [
Expanded(
@@ -2540,38 +2500,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
),
],
),
const SizedBox(height: 16),
// Discover button
if (widget.repeaters.isNotEmpty)
FilledButton.tonalIcon(
onPressed: _isDiscovering ? null : _discoverRegions,
icon: _isDiscovering
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search_rounded, size: 18),
label: Text(
_isDiscovering
? l10n.discoveringRegions
: l10n.discoverRegions,
),
),
// Discovered regions
if (_discoveredRegions.isNotEmpty) ...[
const SizedBox(height: 12),
for (final region in _discoveredRegions) ...[
_RegionOptionTile(
label: region,
isSelected: widget.currentScopeName == region,
onTap: () => widget.onScopeSelected(region),
),
const SizedBox(height: 4),
],
],
],
),
),