mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Show recipient activity previews
This commit is contained in:
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 124;
|
CURRENT_PROJECT_VERSION = 125;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>124</string>
|
<string>125</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000216">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.144858">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.086406">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="98.142592">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="96.919368">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="157.12775">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="563.579992">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import '../utils/avatar_label_helper.dart';
|
|||||||
import '../widgets/common/contact_avatar.dart';
|
import '../widgets/common/contact_avatar.dart';
|
||||||
import '../widgets/contacts/contact_tile.dart';
|
import '../widgets/contacts/contact_tile.dart';
|
||||||
import '../widgets/contacts/add_channel_dialog.dart';
|
import '../widgets/contacts/add_channel_dialog.dart';
|
||||||
|
import '../services/region_scope_preferences.dart';
|
||||||
|
import '../utils/toast_logger.dart';
|
||||||
import 'add_contact_screen.dart';
|
import 'add_contact_screen.dart';
|
||||||
|
|
||||||
class ContactsTab extends StatefulWidget {
|
class ContactsTab extends StatefulWidget {
|
||||||
@@ -546,6 +548,15 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
await _exportHashChannelPskBase64(context, channel);
|
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)
|
if (!channel.isPublicChannel)
|
||||||
_ChannelSheetAction(
|
_ChannelSheetAction(
|
||||||
icon: Icons.delete_outline_rounded,
|
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) {
|
Color _sectionAccentColor(BuildContext context, ContactSection section) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
switch (section) {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import '../providers/image_provider.dart' as ip;
|
|||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
import '../services/region_scope_preferences.dart';
|
import '../services/region_scope_preferences.dart';
|
||||||
import '../services/region_discovery_service.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -1763,12 +1762,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _showRegionScopeSheet() {
|
void _showRegionScopeSheet() {
|
||||||
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final repeaters = contactsProvider.contacts
|
|
||||||
.where((c) => c.isRepeater)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1777,8 +1771,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return _RegionScopeSheet(
|
return _RegionScopeSheet(
|
||||||
currentScopeName: _channelRegionScopeName,
|
currentScopeName: _channelRegionScopeName,
|
||||||
repeaters: repeaters,
|
|
||||||
connectionProvider: connectionProvider,
|
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
onScopeSelected: (String? name) async {
|
onScopeSelected: (String? name) async {
|
||||||
Navigator.of(sheetContext).pop();
|
Navigator.of(sheetContext).pop();
|
||||||
@@ -2412,15 +2404,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
/// Bottom sheet for selecting a region scope for the current channel.
|
/// Bottom sheet for selecting a region scope for the current channel.
|
||||||
class _RegionScopeSheet extends StatefulWidget {
|
class _RegionScopeSheet extends StatefulWidget {
|
||||||
final String? currentScopeName;
|
final String? currentScopeName;
|
||||||
final List<Contact> repeaters;
|
|
||||||
final ConnectionProvider connectionProvider;
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final ValueChanged<String?> onScopeSelected;
|
final ValueChanged<String?> onScopeSelected;
|
||||||
|
|
||||||
const _RegionScopeSheet({
|
const _RegionScopeSheet({
|
||||||
required this.currentScopeName,
|
required this.currentScopeName,
|
||||||
required this.repeaters,
|
|
||||||
required this.connectionProvider,
|
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.onScopeSelected,
|
required this.onScopeSelected,
|
||||||
});
|
});
|
||||||
@@ -2431,8 +2419,6 @@ class _RegionScopeSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
||||||
final TextEditingController _nameController = TextEditingController();
|
final TextEditingController _nameController = TextEditingController();
|
||||||
List<String> _discoveredRegions = [];
|
|
||||||
bool _isDiscovering = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -2440,30 +2426,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
|||||||
super.dispose();
|
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() {
|
void _submitManualName() {
|
||||||
var name = _nameController.text.trim();
|
var name = _nameController.text.trim();
|
||||||
if (name.isEmpty) return;
|
if (name.isEmpty) return;
|
||||||
@@ -2504,7 +2466,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// "None" option
|
|
||||||
_RegionOptionTile(
|
_RegionOptionTile(
|
||||||
label: l10n.regionScopeNone,
|
label: l10n.regionScopeNone,
|
||||||
isSelected: widget.currentScopeName == null,
|
isSelected: widget.currentScopeName == null,
|
||||||
@@ -2512,7 +2473,6 @@ class _RegionScopeSheetState extends State<_RegionScopeSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Manual entry
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
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),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../providers/connection_provider.dart';
|
|
||||||
|
|
||||||
/// Discovers available regions from repeater contacts via anonymous requests.
|
|
||||||
///
|
|
||||||
/// The firmware repeater responds to ANON_REQ_TYPE_REGIONS (0x01) with a
|
|
||||||
/// comma-separated list of region names that have flood allowed.
|
|
||||||
class RegionDiscoveryService {
|
|
||||||
static const int _anonReqTypeRegions = 0x01;
|
|
||||||
|
|
||||||
/// Discover regions from a single repeater.
|
|
||||||
///
|
|
||||||
/// Sends an anonymous request to the repeater and waits for the response.
|
|
||||||
/// Returns a list of region names (with `#` prefix).
|
|
||||||
/// Returns empty list on timeout or error.
|
|
||||||
static Future<List<String>> discoverFromRepeater({
|
|
||||||
required Uint8List repeaterPublicKey,
|
|
||||||
required ConnectionProvider connectionProvider,
|
|
||||||
Duration timeout = const Duration(seconds: 10),
|
|
||||||
}) async {
|
|
||||||
final result = await connectionProvider.sendAnonRequest(
|
|
||||||
contactPublicKey: repeaterPublicKey,
|
|
||||||
requestData: Uint8List.fromList([_anonReqTypeRegions]),
|
|
||||||
);
|
|
||||||
if (result == null) return [];
|
|
||||||
|
|
||||||
final tag = result.tag;
|
|
||||||
final completer = Completer<List<String>>();
|
|
||||||
|
|
||||||
void onResponse(Uint8List publicKeyPrefix, int responseTag, Uint8List data) {
|
|
||||||
if (responseTag != tag || completer.isCompleted) return;
|
|
||||||
completer.complete(_parseRegionResponse(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionProvider.onBinaryResponse = onResponse;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await completer.future.timeout(
|
|
||||||
timeout,
|
|
||||||
onTimeout: () => <String>[],
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [RegionDiscovery] Error discovering regions: $e');
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
// Restore previous handler — callers should re-set if needed
|
|
||||||
if (connectionProvider.onBinaryResponse == onResponse) {
|
|
||||||
connectionProvider.onBinaryResponse = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the region response payload.
|
|
||||||
///
|
|
||||||
/// Format: [4B sender_timestamp][4B repeater_clock][comma-separated names]
|
|
||||||
/// Names are returned without `#` prefix from firmware; we add it back.
|
|
||||||
static List<String> _parseRegionResponse(Uint8List data) {
|
|
||||||
if (data.length <= 8) return [];
|
|
||||||
|
|
||||||
final namesStr = utf8.decode(data.sublist(8), allowMalformed: true).trim();
|
|
||||||
if (namesStr.isEmpty || namesStr == '-none-') return [];
|
|
||||||
|
|
||||||
return namesStr
|
|
||||||
.split(',')
|
|
||||||
.map((name) => name.trim())
|
|
||||||
.where((name) => name.isNotEmpty && name != '*' && !name.startsWith('\$'))
|
|
||||||
.map((name) => name.startsWith('#') ? name : '#$name')
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,30 @@ Future<void> _initializeConnectedWorkspace({
|
|||||||
await appProvider.initialize();
|
await appProvider.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _normalizeConnectionError(Object error) {
|
||||||
|
var message = error.toString();
|
||||||
|
if (message.startsWith('Exception: ')) {
|
||||||
|
message = message.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
if (message.startsWith('Connection failed: Exception: ')) {
|
||||||
|
return message.substring('Connection failed: Exception: '.length);
|
||||||
|
}
|
||||||
|
if (message.startsWith('Connection failed: ')) {
|
||||||
|
return message.substring('Connection failed: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showConnectionErrorSnackBar(BuildContext context, Object error) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_normalizeConnectionError(error)),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> showConnectionDialogFlow(
|
Future<bool> showConnectionDialogFlow(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Color? backgroundColor,
|
Color? backgroundColor,
|
||||||
@@ -36,6 +60,21 @@ Future<bool> showConnectionDialogFlow(
|
|||||||
return result == _ConnectionDialogResult.connected;
|
return result == _ConnectionDialogResult.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _initializeConnectedWorkspace(
|
||||||
|
profileWorkspaceCoordinator: context.read<ProfileWorkspaceCoordinator>(),
|
||||||
|
appProvider: context.read<AppProvider>(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (context.mounted) {
|
||||||
|
_showConnectionErrorSnackBar(context, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!offerPostConnectRepeaterDiscovery) {
|
if (!offerPostConnectRepeaterDiscovery) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -182,43 +221,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
return Colors.red;
|
return Colors.red;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSuccessfulConnection() async {
|
void _closeOnSuccessfulConnection() {
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
final profileWorkspaceCoordinator = context
|
|
||||||
.read<ProfileWorkspaceCoordinator>();
|
|
||||||
|
|
||||||
await _initializeConnectedWorkspace(
|
|
||||||
profileWorkspaceCoordinator: profileWorkspaceCoordinator,
|
|
||||||
appProvider: appProvider,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop(_ConnectionDialogResult.connected);
|
Navigator.of(context).pop(_ConnectionDialogResult.connected);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _normalizeConnectionError(Object error) {
|
|
||||||
var message = error.toString();
|
|
||||||
if (message.startsWith('Exception: ')) {
|
|
||||||
message = message.substring('Exception: '.length);
|
|
||||||
}
|
|
||||||
if (message.startsWith('Connection failed: Exception: ')) {
|
|
||||||
return message.substring('Connection failed: Exception: '.length);
|
|
||||||
}
|
|
||||||
if (message.startsWith('Connection failed: ')) {
|
|
||||||
return message.substring('Connection failed: '.length);
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showConnectionError(Object error) {
|
void _showConnectionError(Object error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showConnectionErrorSnackBar(context, error);
|
||||||
SnackBar(
|
|
||||||
content: Text(_normalizeConnectionError(error)),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -554,7 +564,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
'Failed to connect to $name',
|
'Failed to connect to $name',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _handleSuccessfulConnection();
|
_closeOnSuccessfulConnection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_showConnectionError(error);
|
_showConnectionError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -676,7 +686,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
'Failed to connect to ${server.ipAddress}:${server.port}',
|
'Failed to connect to ${server.ipAddress}:${server.port}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await _handleSuccessfulConnection();
|
_closeOnSuccessfulConnection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -883,11 +893,6 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await _initializeConnectedWorkspace(
|
|
||||||
profileWorkspaceCoordinator: context
|
|
||||||
.read<ProfileWorkspaceCoordinator>(),
|
|
||||||
appProvider: context.read<AppProvider>(),
|
|
||||||
);
|
|
||||||
widget.onConnected(_ConnectionDialogResult.connected);
|
widget.onConnected(_ConnectionDialogResult.connected);
|
||||||
} else {
|
} else {
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../models/sar_template.dart';
|
|||||||
import '../../models/map_drawing.dart';
|
import '../../models/map_drawing.dart';
|
||||||
import '../../models/map_coordinate_space.dart';
|
import '../../models/map_coordinate_space.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
|
import '../../providers/channels_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/drawing_provider.dart';
|
import '../../providers/drawing_provider.dart';
|
||||||
@@ -1955,10 +1956,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
final isSarMarker = message.isSarMarker;
|
final isSarMarker = message.isSarMarker;
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
final messageFontScale = context.watch<AppProvider>().messageFontScale;
|
final messageFontScale = context.watch<AppProvider>().messageFontScale;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
// Determine if this is own message
|
// Determine if this is own message
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final channelsProvider = context.watch<ChannelsProvider>();
|
||||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
final isOwnMessage =
|
final isOwnMessage =
|
||||||
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
message.isSentMessage || message.isFromSelf(selfPublicKey);
|
||||||
@@ -2004,7 +2007,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
// Get rich display name (with emoji if available)
|
// Get rich display name (with emoji if available)
|
||||||
final displayName = isOwnMessage
|
final displayName = isOwnMessage
|
||||||
? AppLocalizations.of(context)!.you
|
? l10n.you
|
||||||
: message.getRichDisplayName(senderContact);
|
: message.getRichDisplayName(senderContact);
|
||||||
|
|
||||||
// Look up destination/source display labels for direct/channel messages
|
// Look up destination/source display labels for direct/channel messages
|
||||||
@@ -2039,15 +2042,30 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (message.isChannelMessage) {
|
} else if (message.isChannelMessage) {
|
||||||
if (message.channelIdx == 0) {
|
final channelIdx = message.channelIdx ?? 0;
|
||||||
channelDisplayName = AppLocalizations.of(context)!.publicChannel;
|
if (channelIdx == 0) {
|
||||||
|
channelDisplayName = l10n.publicChannel;
|
||||||
} else {
|
} else {
|
||||||
final channelContact = contactsProvider.channels.where((c) {
|
final channelContact = contactsProvider.channels.where((c) {
|
||||||
return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx;
|
return c.publicKey.length > 1 && c.publicKey[1] == channelIdx;
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
final syncedChannel = channelsProvider.getChannel(channelIdx);
|
||||||
|
final syncedChannelDisplayName =
|
||||||
|
syncedChannel != null && syncedChannel.hasCustomName
|
||||||
|
? syncedChannel.displayName
|
||||||
|
: null;
|
||||||
|
final contactChannelDisplayName = channelContact
|
||||||
|
?.getLocalizedDisplayName(context)
|
||||||
|
.trim();
|
||||||
|
|
||||||
channelDisplayName =
|
channelDisplayName =
|
||||||
channelContact?.getLocalizedDisplayName(context) ??
|
syncedChannelDisplayName ??
|
||||||
'${AppLocalizations.of(context)!.channel} ${message.channelIdx}';
|
(contactChannelDisplayName != null &&
|
||||||
|
contactChannelDisplayName.isNotEmpty
|
||||||
|
? contactChannelDisplayName
|
||||||
|
: null) ??
|
||||||
|
syncedChannel?.displayName ??
|
||||||
|
'${l10n.channel} $channelIdx';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOwnMessage) {
|
if (isOwnMessage) {
|
||||||
@@ -2057,14 +2075,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
|
|
||||||
final recipientSubtitle =
|
final recipientSubtitle =
|
||||||
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
|
isOwnMessage && message.isChannelMessage && recipientDisplayName != null
|
||||||
? '${AppLocalizations.of(context)!.channel}: $recipientDisplayName'
|
? '${l10n.channel}: $recipientDisplayName'
|
||||||
: recipientDisplayName;
|
: recipientDisplayName;
|
||||||
final directCounterpartLabel = !message.isChannelMessage
|
final directCounterpartLabel = !message.isChannelMessage
|
||||||
? (isOwnMessage ? recipientSubtitle : AppLocalizations.of(context)!.you)
|
? (isOwnMessage ? recipientSubtitle : l10n.you)
|
||||||
: null;
|
: null;
|
||||||
final receivedChannelSubtitle =
|
final receivedChannelSubtitle =
|
||||||
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
|
!isOwnMessage && message.isChannelMessage && channelDisplayName != null
|
||||||
? '${AppLocalizations.of(context)!.channel}: $channelDisplayName'
|
? '${l10n.channel}: $channelDisplayName'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final shouldFloatBubble = widget.isCompact;
|
final shouldFloatBubble = widget.isCompact;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
|
|
||||||
enum _RecipientSortMode { activity, favorites, alphabetical }
|
enum _RecipientSortMode { activity, favorites, alphabetical }
|
||||||
@@ -17,6 +20,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
final String? currentRecipientPublicKey;
|
final String? currentRecipientPublicKey;
|
||||||
final bool showAllOption;
|
final bool showAllOption;
|
||||||
final Function(String type, Contact? recipient) onSelect;
|
final Function(String type, Contact? recipient) onSelect;
|
||||||
|
final MessagesProvider? messagesProvider;
|
||||||
|
|
||||||
/// Region scope names per channel index (e.g. {0: "#auckland"}).
|
/// Region scope names per channel index (e.g. {0: "#auckland"}).
|
||||||
final Map<int, String> channelRegionScopes;
|
final Map<int, String> channelRegionScopes;
|
||||||
@@ -32,6 +36,7 @@ class RecipientSelectorSheet extends StatefulWidget {
|
|||||||
this.currentRecipientPublicKey,
|
this.currentRecipientPublicKey,
|
||||||
this.showAllOption = true,
|
this.showAllOption = true,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
this.messagesProvider,
|
||||||
this.channelRegionScopes = const {},
|
this.channelRegionScopes = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,20 +172,162 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _channelSubtitle(BuildContext context, Contact channel) {
|
MessagesProvider? _resolveMessagesProvider(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
if (widget.messagesProvider != null) {
|
||||||
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
return widget.messagesProvider;
|
||||||
final scopeName = widget.channelRegionScopes[channelIdx];
|
|
||||||
|
|
||||||
if (channel.isPublicChannel) {
|
|
||||||
return scopeName != null
|
|
||||||
? '${l10n.broadcastToAllNearby} • $scopeName'
|
|
||||||
: l10n.broadcastToAllNearby;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final shortKey = channel.publicKeyShort.toUpperCase();
|
try {
|
||||||
final base = '${l10n.channel} $channelIdx • $shortKey';
|
return Provider.of<MessagesProvider>(context);
|
||||||
return scopeName != null ? '$base • $scopeName' : base;
|
} on ProviderNotFoundException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final diff = DateTime.now().difference(when);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) return l10n.justNow;
|
||||||
|
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||||
|
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||||
|
return l10n.daysAgo(diff.inDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ChannelPreviewData _channelPreviewData(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
MessagesProvider? messagesProvider,
|
||||||
|
) {
|
||||||
|
if (messagesProvider == null) {
|
||||||
|
return const _ChannelPreviewData();
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastActivityAt = messagesProvider.getLastActivityForDestination(
|
||||||
|
channel,
|
||||||
|
);
|
||||||
|
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||||
|
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
|
||||||
|
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||||
|
final participantNames = <String>[];
|
||||||
|
|
||||||
|
for (final message in channelMessages) {
|
||||||
|
final senderName = message.senderName?.trim();
|
||||||
|
if (senderName == null || senderName.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!participantNames.contains(senderName)) {
|
||||||
|
participantNames.add(senderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ChannelPreviewData(
|
||||||
|
activityLabel: lastActivityAt == null
|
||||||
|
? null
|
||||||
|
: _formatRelativeTime(context, lastActivityAt),
|
||||||
|
participantNames: participantNames,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? _findParticipantContact(String name) {
|
||||||
|
final normalizedName = name.trim();
|
||||||
|
|
||||||
|
for (final contact in widget.contacts) {
|
||||||
|
if (!contact.isChannel && contact.advName.trim() == normalizedName) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in widget.contacts) {
|
||||||
|
if (!contact.isChannel && contact.displayName.trim() == normalizedName) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _contactActivityLabel(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
MessagesProvider? messagesProvider,
|
||||||
|
) {
|
||||||
|
final lastActivityAt =
|
||||||
|
messagesProvider?.getLastActivityForDestination(contact) ??
|
||||||
|
contact.lastSeenTime;
|
||||||
|
|
||||||
|
return _formatRelativeTime(context, lastActivityAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextSubtitle(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
String subtitle,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: contact.isChannel ? null : 'monospace',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelSubtitle(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
_ChannelPreviewData previewData,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (previewData.participantNames.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
'No recent chatters',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_ParticipantAvatarStack(
|
||||||
|
key: Key('channel-participants-${channel.publicKeyHex}'),
|
||||||
|
names: previewData.participantNames,
|
||||||
|
contactForName: _findParticipantContact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelRecipientCard(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
MessagesProvider? messagesProvider,
|
||||||
|
) {
|
||||||
|
final previewData = _channelPreviewData(context, channel, messagesProvider);
|
||||||
|
|
||||||
|
return _buildRecipientCard(
|
||||||
|
context: context,
|
||||||
|
type: 'channel',
|
||||||
|
contact: channel,
|
||||||
|
title: channel.getLocalizedDisplayName(context),
|
||||||
|
subtitle: _buildChannelSubtitle(context, channel, previewData),
|
||||||
|
unreadCount: _unreadFor(channel),
|
||||||
|
isSelected: _isSelected('channel', channel),
|
||||||
|
compact: true,
|
||||||
|
activityLabel: previewData.activityLabel,
|
||||||
|
onTap: () {
|
||||||
|
widget.onSelect('channel', channel);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
|
bool _isDenseSection(String type) => type == 'channel' || type == 'contact';
|
||||||
@@ -189,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final messagesProvider = _resolveMessagesProvider(context);
|
||||||
final filteredContacts = _filterAndSortContacts(widget.contacts);
|
final filteredContacts = _filterAndSortContacts(widget.contacts);
|
||||||
final filteredRooms = _filterAndSortContacts(widget.rooms);
|
final filteredRooms = _filterAndSortContacts(widget.rooms);
|
||||||
final filteredChannels = _filterAndSortContacts(
|
final filteredChannels = _filterAndSortContacts(
|
||||||
@@ -335,19 +483,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
emptyLabel: l10n.noChannelsFound,
|
emptyLabel: l10n.noChannelsFound,
|
||||||
children: [
|
children: [
|
||||||
for (final channel in filteredChannels)
|
for (final channel in filteredChannels)
|
||||||
_buildRecipientCard(
|
_buildChannelRecipientCard(
|
||||||
context: context,
|
context,
|
||||||
type: 'channel',
|
channel,
|
||||||
contact: channel,
|
messagesProvider,
|
||||||
title: channel.getLocalizedDisplayName(context),
|
|
||||||
subtitle: _channelSubtitle(context, channel),
|
|
||||||
unreadCount: _unreadFor(channel),
|
|
||||||
isSelected: _isSelected('channel', channel),
|
|
||||||
compact: true,
|
|
||||||
onTap: () {
|
|
||||||
widget.onSelect('channel', channel);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -366,7 +505,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
type: 'contact',
|
type: 'contact',
|
||||||
contact: contact,
|
contact: contact,
|
||||||
title: contact.displayName,
|
title: contact.displayName,
|
||||||
subtitle: contact.publicKeyShort,
|
activityLabel: _contactActivityLabel(
|
||||||
|
context,
|
||||||
|
contact,
|
||||||
|
messagesProvider,
|
||||||
|
),
|
||||||
unreadCount: _unreadFor(contact),
|
unreadCount: _unreadFor(contact),
|
||||||
isSelected: _isSelected('contact', contact),
|
isSelected: _isSelected('contact', contact),
|
||||||
compact: true,
|
compact: true,
|
||||||
@@ -392,7 +535,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
type: 'room',
|
type: 'room',
|
||||||
contact: room,
|
contact: room,
|
||||||
title: room.displayName,
|
title: room.displayName,
|
||||||
subtitle: room.publicKeyShort,
|
subtitle: _buildTextSubtitle(
|
||||||
|
context,
|
||||||
|
room,
|
||||||
|
room.publicKeyShort,
|
||||||
|
),
|
||||||
unreadCount: _unreadFor(room),
|
unreadCount: _unreadFor(room),
|
||||||
isSelected: _isSelected('room', room),
|
isSelected: _isSelected('room', room),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -690,10 +837,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
required String type,
|
required String type,
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
Widget? subtitle,
|
||||||
required int unreadCount,
|
required int unreadCount,
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
bool compact = false,
|
bool compact = false,
|
||||||
|
String? activityLabel,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -757,52 +905,70 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.titleSmall
|
title,
|
||||||
?.copyWith(
|
maxLines: 1,
|
||||||
fontWeight: FontWeight.w800,
|
overflow: TextOverflow.ellipsis,
|
||||||
letterSpacing: -0.2,
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (contact.isPublicChannel) ...[
|
||||||
|
SizedBox(width: compact ? 6 : 8),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 7 : 8,
|
||||||
|
vertical: compact ? 2 : 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(
|
||||||
|
alpha: 0.10,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Public',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: accentColor,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (contact.isPublicChannel) ...[
|
if (activityLabel != null) ...[
|
||||||
SizedBox(width: compact ? 6 : 8),
|
SizedBox(width: compact ? 8 : 10),
|
||||||
Container(
|
Text(
|
||||||
padding: EdgeInsets.symmetric(
|
activityLabel,
|
||||||
horizontal: compact ? 7 : 8,
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
vertical: compact ? 2 : 3,
|
?.copyWith(
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
decoration: BoxDecoration(
|
fontWeight: FontWeight.w700,
|
||||||
color: accentColor.withValues(alpha: 0.10),
|
),
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Public',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: accentColor,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 2 : 4),
|
if (subtitle != null) ...[
|
||||||
Text(
|
SizedBox(height: compact ? 2 : 4),
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontFamily: contact.isChannel ? null : 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -858,3 +1024,140 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChannelPreviewData {
|
||||||
|
final String? activityLabel;
|
||||||
|
final List<String> participantNames;
|
||||||
|
|
||||||
|
const _ChannelPreviewData({
|
||||||
|
this.activityLabel,
|
||||||
|
this.participantNames = const <String>[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParticipantAvatarStack extends StatelessWidget {
|
||||||
|
final List<String> names;
|
||||||
|
final Contact? Function(String name) contactForName;
|
||||||
|
static const int _visibleCount = 4;
|
||||||
|
|
||||||
|
const _ParticipantAvatarStack({
|
||||||
|
super.key,
|
||||||
|
required this.names,
|
||||||
|
required this.contactForName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final visibleNames = names.take(_visibleCount).toList();
|
||||||
|
final overflowCount = names.length - visibleNames.length;
|
||||||
|
const avatarSize = 20.0;
|
||||||
|
const spacing = 14.0;
|
||||||
|
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
|
||||||
|
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: avatarSize,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < visibleNames.length; i++)
|
||||||
|
Positioned(
|
||||||
|
left: i * spacing,
|
||||||
|
top: 0,
|
||||||
|
child: _ParticipantAvatar(
|
||||||
|
name: visibleNames[i],
|
||||||
|
contact: contactForName(visibleNames[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (overflowCount > 0)
|
||||||
|
Positioned(
|
||||||
|
left: visibleNames.length * spacing,
|
||||||
|
top: 0,
|
||||||
|
child: _ParticipantOverflowAvatar(count: overflowCount),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParticipantOverflowAvatar extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const _ParticipantOverflowAvatar({required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: colorScheme.surface, width: 2),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'+$count',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParticipantAvatar extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final Contact? contact;
|
||||||
|
|
||||||
|
const _ParticipantAvatar({required this.name, required this.contact});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (contact != null) {
|
||||||
|
final surfaceColor = Theme.of(context).colorScheme.surface;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: surfaceColor, width: 2),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 6)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: colorScheme.surface, width: 2),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
AvatarLabelHelper.buildLabel(name),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0322.1+38
|
version: 2026.0322.2+39
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
@@ -34,6 +35,24 @@ class _FakeConnectionProvider extends ConnectionProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ConnectableFakeConnectionProvider extends ConnectionProvider {
|
||||||
|
int connectCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ScannedDevice> get scannedDevices => [
|
||||||
|
ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get error => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> connect(BluetoothDevice device) async {
|
||||||
|
connectCalls += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('BLE scan waits for explicit user action', (tester) async {
|
testWidgets('BLE scan waits for explicit user action', (tester) async {
|
||||||
final connectionProvider = _FakeConnectionProvider();
|
final connectionProvider = _FakeConnectionProvider();
|
||||||
@@ -64,4 +83,47 @@ void main() {
|
|||||||
expect(connectionProvider.stopScanCalls, 1);
|
expect(connectionProvider.stopScanCalls, 1);
|
||||||
expect(connectionProvider.startScanCalls, 1);
|
expect(connectionProvider.startScanCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('successful BLE connect closes the dialog immediately', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connectionProvider = _ConnectableFakeConnectionProvider();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ChangeNotifierProvider<ConnectionProvider>.value(
|
||||||
|
value: connectionProvider,
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet<Object?>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => const ConnectionDialog(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(ConnectionDialog), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connectionProvider.connectCalls, 1);
|
||||||
|
expect(find.byType(ConnectionDialog), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,44 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('channel bubbles refresh to synced channel names', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = await _TestHarness.create();
|
||||||
|
try {
|
||||||
|
final message = Message(
|
||||||
|
id: 'channel-name-refresh',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: _prefix(61),
|
||||||
|
channelIdx: 3,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Team update',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(harness, message));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Channel 3'), findsOneWidget);
|
||||||
|
expect(find.text('#slovenija'), findsNothing);
|
||||||
|
|
||||||
|
harness.channelsProvider.addOrUpdateChannel(
|
||||||
|
index: 3,
|
||||||
|
name: '#slovenija',
|
||||||
|
secret: Uint8List(16),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('#slovenija'), findsOneWidget);
|
||||||
|
expect(find.text('Channel 3'), findsNothing);
|
||||||
|
} finally {
|
||||||
|
await _disposeHarness(tester, harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('message bubble detects and opens links', (tester) async {
|
testWidgets('message bubble detects and opens links', (tester) async {
|
||||||
final harness = await _TestHarness.create();
|
final harness = await _TestHarness.create();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart';
|
import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -31,13 +33,18 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pumpSheet(WidgetTester tester) async {
|
Future<void> pumpSheet(
|
||||||
final channel = buildContact(
|
WidgetTester tester, {
|
||||||
name: 'Ops',
|
List<Contact>? contacts,
|
||||||
type: ContactType.channel,
|
List<Contact>? channels,
|
||||||
secondByte: 3,
|
MessagesProvider? messagesProvider,
|
||||||
);
|
bool showAllOption = true,
|
||||||
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
|
}) async {
|
||||||
|
final resolvedChannels =
|
||||||
|
channels ??
|
||||||
|
[buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)];
|
||||||
|
final resolvedContacts =
|
||||||
|
contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)];
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@@ -45,15 +52,17 @@ void main() {
|
|||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: RecipientSelectorSheet(
|
body: RecipientSelectorSheet(
|
||||||
contacts: [contact],
|
contacts: resolvedContacts,
|
||||||
rooms: const [],
|
rooms: const [],
|
||||||
channels: [channel],
|
channels: resolvedChannels,
|
||||||
unreadCount: 11,
|
unreadCount: 11,
|
||||||
unreadCountsByPublicKey: {
|
unreadCountsByPublicKey: {
|
||||||
channel.publicKeyHex: 7,
|
for (final channel in resolvedChannels) channel.publicKeyHex: 7,
|
||||||
contact.publicKeyHex: 3,
|
for (final contact in resolvedContacts) contact.publicKeyHex: 3,
|
||||||
},
|
},
|
||||||
currentDestinationType: 'all',
|
currentDestinationType: 'all',
|
||||||
|
showAllOption: showAllOption,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
onSelect: (selectedContact, destinationType) {},
|
onSelect: (selectedContact, destinationType) {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -150,4 +159,49 @@ void main() {
|
|||||||
expect(charlieY, lessThan(bravoY));
|
expect(charlieY, lessThan(bravoY));
|
||||||
expect(bravoY, lessThan(alphaY));
|
expect(bravoY, lessThan(alphaY));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows channel activity and participants instead of raw ids', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final channel = buildContact(
|
||||||
|
name: 'Ops',
|
||||||
|
type: ContactType.channel,
|
||||||
|
secondByte: 3,
|
||||||
|
);
|
||||||
|
final messagesProvider = MessagesProvider()
|
||||||
|
..addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'channel-activity',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderName: 'Radio Alpha',
|
||||||
|
channelIdx: 3,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp:
|
||||||
|
DateTime.now()
|
||||||
|
.subtract(const Duration(minutes: 5))
|
||||||
|
.millisecondsSinceEpoch ~/
|
||||||
|
1000,
|
||||||
|
text: 'status update',
|
||||||
|
receivedAt: DateTime.now().subtract(const Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpSheet(
|
||||||
|
tester,
|
||||||
|
contacts: const [],
|
||||||
|
channels: [channel],
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
showAllOption: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(Key('channel-participants-${channel.publicKeyHex}')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('Radio Alpha'), findsNothing);
|
||||||
|
expect(find.text('5m ago'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Channel 3'), findsNothing);
|
||||||
|
expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user