mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-12 20:02:01 +10:00
e1a4900dc0
This adds a simple UI in repeater management settings for remotely setting a repeater's private key. A key can be pasted from an external key generator, or a random one can be generated here, optionally with a specified prefix. If a prefix is specified, searching is done in 30ms batches in order to keep the UI responsive and show the progress indicator animation smoothly. Searches can be interrupted. An estimation of search length is given as expected number of generated key pairs. (A future improvement would be to observe keys per second performance and give time estimates based on that.) When it generates a new private key on your behalf, it can tell you what the corresponding public key will be. Unfortunately, if you paste one in from another source, it can't compute the corresponding public key. This is because the standard Dart crypto library doesn't provide that capability, nor does it give access to the elliptic curve functions it implements so we could do it ourselves. Our only recourse would be to copy the source for all those things, and I didn't think it was worth it. This will not check the validity of a pasted key, but the remote repeater will. It does limit it to the correct length and hexadecimal charset. Prefixes are limited to 4 characters. There's no technical reason for this, but users are unlikely to want more, and I think it would just be a bad experience to allow more and spin for a very long time. If someone really wants a longer one, they can use an external generator like they do now. Possible TODO: rewrite background processing using isolates. Closes #142.
2018 lines
78 KiB
Dart
2018 lines
78 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:meshcore_open/storage/channel_message_store.dart';
|
|
import 'package:meshcore_open/utils/keys.dart';
|
|
import 'package:meshcore_open/utils/platform_info.dart';
|
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/ui_view_state_service.dart';
|
|
import '../models/channel.dart';
|
|
import '../models/community.dart';
|
|
import '../storage/community_store.dart';
|
|
import '../theme/mesh_theme.dart';
|
|
import '../utils/dialog_utils.dart';
|
|
import '../utils/disconnect_navigation_mixin.dart';
|
|
import '../utils/route_transitions.dart';
|
|
import '../widgets/list_filter_widget.dart';
|
|
import '../widgets/empty_state.dart';
|
|
import '../widgets/mesh_ui.dart';
|
|
import '../widgets/qr_code_display.dart';
|
|
import '../widgets/quick_switch_bar.dart';
|
|
import '../widgets/sync_progress_overlay.dart';
|
|
import '../widgets/unread_badge.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import 'channel_chat_screen.dart';
|
|
import 'community_qr_scanner_screen.dart';
|
|
import 'contacts_screen.dart';
|
|
import 'map_screen.dart';
|
|
import 'settings_screen.dart';
|
|
|
|
class ChannelsScreen extends StatefulWidget {
|
|
final bool hideBackButton;
|
|
|
|
const ChannelsScreen({super.key, this.hideBackButton = false});
|
|
|
|
@override
|
|
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
|
}
|
|
|
|
class _ChannelsScreenState extends State<ChannelsScreen>
|
|
with DisconnectNavigationMixin {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final CommunityStore _communityStore = CommunityStore();
|
|
final CommunityPskIndex _communityIndex = CommunityPskIndex();
|
|
List<Community> _communities = [];
|
|
Timer? _searchDebounce;
|
|
|
|
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchController.text = context
|
|
.read<UiViewStateService>()
|
|
.channelsSearchText;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<MeshCoreConnector>().getChannels();
|
|
_loadCommunities();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadCommunities() async {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
|
final communities = await _communityStore.loadCommunities();
|
|
if (mounted) {
|
|
setState(() {
|
|
_communities = communities;
|
|
_communityIndex.initialize(communities);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounce?.cancel();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String _relativeTime(DateTime t) {
|
|
final diff = DateTime.now().difference(t);
|
|
if (diff.inMinutes < 1) return 'now';
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
|
|
if (diff.inHours < 24) return '${diff.inHours}h';
|
|
return '${diff.inDays}d';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final viewState = context.watch<UiViewStateService>();
|
|
|
|
final channelMessageStore = ChannelMessageStore();
|
|
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
|
|
|
// Auto-navigate back to scanner if disconnected
|
|
if (!checkConnectionAndNavigate(connector)) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final allowBack = !connector.isConnected;
|
|
|
|
return PopScope(
|
|
canPop: allowBack,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: AppBarTitle(context.l10n.channels_title),
|
|
centerTitle: true,
|
|
automaticallyImplyLeading: false,
|
|
bottom: const SyncProgressAppBarBottom(),
|
|
actions: [
|
|
PopupMenuButton(
|
|
// onTap handlers run after the menu route pops, so they must
|
|
// capture the screen's context — not the itemBuilder's menu
|
|
// context, which is deactivated by then.
|
|
itemBuilder: (menuContext) => [
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.logout,
|
|
color: Theme.of(menuContext).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(menuContext.l10n.common_disconnect),
|
|
],
|
|
),
|
|
onTap: () => _disconnect(context),
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.groups),
|
|
const SizedBox(width: 8),
|
|
Text(menuContext.l10n.community_manageCommunities),
|
|
],
|
|
),
|
|
onTap: () => _showManageCommunitiesDialog(context),
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.settings),
|
|
const SizedBox(width: 8),
|
|
Text(menuContext.l10n.settings_title),
|
|
],
|
|
),
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const SettingsScreen(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
icon: const Icon(Icons.more_vert),
|
|
),
|
|
],
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await context.read<MeshCoreConnector>().getChannels(force: true);
|
|
},
|
|
child: () {
|
|
final channels = connector.channels;
|
|
final waitingForFirstChannel =
|
|
connector.isLoadingChannels && channels.isEmpty;
|
|
|
|
// Only block the list while the first channel is actively loading.
|
|
// If the initial sync aborts, show cached/partial channels instead
|
|
// of trapping the user behind an idle spinner.
|
|
if (waitingForFirstChannel) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (channels.isEmpty) {
|
|
return ListView(
|
|
children: [
|
|
SizedBox(
|
|
height: MediaQuery.of(context).size.height - 200,
|
|
child: EmptyState(
|
|
icon: Icons.tag,
|
|
title: context.l10n.channels_noChannelsConfigured,
|
|
action: FilledButton.icon(
|
|
onPressed: () => _addPublicChannel(context, connector),
|
|
icon: const Icon(Icons.public),
|
|
label: Text(context.l10n.channels_addPublicChannel),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final filteredChannels = _filterAndSortChannels(
|
|
channels,
|
|
connector,
|
|
viewState,
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.channels_searchChannels,
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (viewState.channelsSearchText.isNotEmpty)
|
|
IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = null;
|
|
_searchController.clear();
|
|
context
|
|
.read<UiViewStateService>()
|
|
.setChannelsSearchText('');
|
|
},
|
|
),
|
|
_buildFilterButton(viewState),
|
|
],
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
onChanged: (value) {
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(
|
|
const Duration(milliseconds: 300),
|
|
() {
|
|
if (!mounted) return;
|
|
context
|
|
.read<UiViewStateService>()
|
|
.setChannelsSearchText(value);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Expanded(
|
|
child: filteredChannels.isEmpty
|
|
? LayoutBuilder(
|
|
builder: (context, constraints) => ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: constraints.maxHeight,
|
|
),
|
|
child: EmptyState(
|
|
icon: Icons.search_off,
|
|
title: context.l10n.channels_noChannelsFound,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: (viewState.channelsSortOption ==
|
|
ChannelSortOption.manual &&
|
|
viewState.channelsSearchText.isEmpty)
|
|
? ReorderableListView.builder(
|
|
padding: const EdgeInsets.only(
|
|
left: 0,
|
|
right: 0,
|
|
top: 8,
|
|
bottom: 88,
|
|
),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: filteredChannels.length,
|
|
onReorderItem: (oldIndex, newIndex) {
|
|
// onReorderItem already adjusts newIndex after the
|
|
// removed item, unlike the deprecated onReorder.
|
|
final reordered = List<Channel>.from(
|
|
filteredChannels,
|
|
);
|
|
final item = reordered.removeAt(oldIndex);
|
|
reordered.insert(newIndex, item);
|
|
unawaited(
|
|
connector.setChannelOrder(
|
|
reordered.map((c) => c.index).toList(),
|
|
),
|
|
);
|
|
},
|
|
itemBuilder: (context, index) {
|
|
final channel = filteredChannels[index];
|
|
return _buildChannelTile(
|
|
context,
|
|
connector,
|
|
channelMessageStore,
|
|
channel,
|
|
showDragHandle: true,
|
|
dragIndex: index,
|
|
listIndex: index,
|
|
);
|
|
},
|
|
)
|
|
: ListView.builder(
|
|
padding: const EdgeInsets.only(
|
|
left: 0,
|
|
right: 0,
|
|
top: 8,
|
|
bottom: 88,
|
|
),
|
|
itemCount: filteredChannels.length,
|
|
itemBuilder: (context, index) {
|
|
final channel = filteredChannels[index];
|
|
return _buildChannelTile(
|
|
context,
|
|
connector,
|
|
channelMessageStore,
|
|
channel,
|
|
listIndex: index,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}(),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => _showAddChannelDialog(context),
|
|
tooltip: context.l10n.channels_addChannel,
|
|
child: const Icon(Icons.add),
|
|
),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
child: QuickSwitchBar(
|
|
selectedIndex: 1,
|
|
onDestinationSelected: (index) =>
|
|
_handleQuickSwitch(index, context),
|
|
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
|
|
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChannelTile(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
ChannelMessageStore channelMessageStore,
|
|
Channel channel, {
|
|
bool showDragHandle = false,
|
|
int? dragIndex,
|
|
int listIndex = 0,
|
|
}) {
|
|
final unreadCount = connector.getUnreadCountForChannel(channel);
|
|
final isMuted = context.watch<AppSettingsService>().isChannelMuted(
|
|
channel.name,
|
|
);
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
// Determine icon and colors based on channel type
|
|
IconData icon;
|
|
Color iconColor;
|
|
final ChannelType channelType = Channel.getChannelType(
|
|
channel,
|
|
_communityIndex,
|
|
);
|
|
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
|
|
final community = isCommunityChannel
|
|
? _communityIndex.getCommunityForChannel(channel)
|
|
: null;
|
|
// Only flood-routed channels carry a region; show it when one is set.
|
|
String subtitle = connector.hasChannelRegion(channel.index)
|
|
? context.l10n.channels_regionSetTo(
|
|
connector.getChannelRegion(channel.index),
|
|
)
|
|
: '';
|
|
switch (channelType) {
|
|
case ChannelType.communityPublic:
|
|
icon = Icons.groups;
|
|
iconColor = MeshPalette.magenta;
|
|
if (community != null) {
|
|
subtitle =
|
|
'${context.l10n.community_publicChannel} • ${community.name}';
|
|
}
|
|
case ChannelType.communityHashtag:
|
|
icon = Icons.groups;
|
|
iconColor = MeshPalette.magenta;
|
|
if (community != null) {
|
|
subtitle =
|
|
'${context.l10n.community_hashtagChannel} • ${community.name}';
|
|
}
|
|
case ChannelType.public:
|
|
icon = Icons.public;
|
|
iconColor = MeshPalette.signal;
|
|
case ChannelType.hashtag:
|
|
icon = Icons.tag;
|
|
iconColor = MeshPalette.blue;
|
|
case ChannelType.private:
|
|
icon = Icons.lock;
|
|
iconColor = MeshPalette.blue;
|
|
}
|
|
|
|
// Last message preview
|
|
final messages = connector.getChannelMessages(channel);
|
|
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
|
final lastPreview = lastMessage?.text ?? '';
|
|
final lastTime = lastMessage?.timestamp;
|
|
|
|
final channelLabel = channel.name.isEmpty
|
|
? context.l10n.channels_channelIndex(channel.index)
|
|
: channel.name;
|
|
|
|
return ListEntrance(
|
|
key: ValueKey('channel_entrance_${channel.index}'),
|
|
index: dragIndex ?? listIndex,
|
|
child: MeshCard(
|
|
key: ValueKey('channel_${channel.index}'),
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
onTap: () {
|
|
HapticFeedback.selectionClick();
|
|
final unread = connector.getUnreadCountForChannelIndex(channel.index);
|
|
connector.markChannelRead(channel.index);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChannelChatScreen(
|
|
channel: channel,
|
|
initialUnreadCount: unread,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
onLongPress: () => _showChannelActions(
|
|
this.context,
|
|
connector,
|
|
channelMessageStore,
|
|
channel,
|
|
),
|
|
onSecondaryTap: PlatformInfo.isDesktop
|
|
? () => _showChannelActions(
|
|
this.context,
|
|
connector,
|
|
channelMessageStore,
|
|
channel,
|
|
)
|
|
: null,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Leading avatar with optional community badge
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
AvatarCircle(
|
|
name: channelLabel,
|
|
size: 42,
|
|
color: iconColor,
|
|
icon: icon,
|
|
),
|
|
if (isCommunityChannel)
|
|
Positioned(
|
|
right: -2,
|
|
bottom: -2,
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: MeshPalette.magenta,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerLow,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: const Icon(
|
|
Icons.people,
|
|
size: 8,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Title + subtitle + ch chip
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
channelLabel,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(fontWeight: FontWeight.w500),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
StatusChip(
|
|
label: 'CH ${channel.index}',
|
|
color: MeshPalette.blue,
|
|
fontSize: 10,
|
|
),
|
|
],
|
|
),
|
|
if (subtitle.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
if (lastPreview.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
lastPreview,
|
|
style: MeshTheme.mono(
|
|
fontSize: 11.5,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Right side: time + unread badge + muted + drag handle
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (lastTime != null)
|
|
Text(
|
|
_relativeTime(lastTime),
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (isMuted) ...[
|
|
Icon(
|
|
Icons.notifications_off,
|
|
size: 14,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
if (unreadCount > 0) UnreadBadge(count: unreadCount),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (showDragHandle && dragIndex != null) ...[
|
|
const SizedBox(width: 4),
|
|
ReorderableDragStartListener(
|
|
index: dragIndex,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Icon(
|
|
Icons.drag_handle,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showChannelActions(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
ChannelMessageStore channelMessageStore,
|
|
Channel channel,
|
|
) {
|
|
final parentContext = context;
|
|
final settingsService = context.read<AppSettingsService>();
|
|
final isMuted = settingsService.isChannelMuted(channel.name);
|
|
|
|
showModalBottomSheet(
|
|
context: parentContext,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.edit_outlined),
|
|
title: Text(sheetContext.l10n.channels_editChannel),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (parentContext.mounted) {
|
|
_showEditChannelDialog(parentContext, connector, channel);
|
|
}
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
isMuted
|
|
? Icons.notifications_outlined
|
|
: Icons.notifications_off_outlined,
|
|
),
|
|
title: Text(
|
|
isMuted
|
|
? sheetContext.l10n.channels_unmuteChannel
|
|
: sheetContext.l10n.channels_muteChannel,
|
|
),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
if (isMuted) {
|
|
await settingsService.unmuteChannel(channel.name);
|
|
} else {
|
|
await settingsService.muteChannel(channel.name);
|
|
}
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.delete_outline,
|
|
color: Theme.of(sheetContext).colorScheme.error,
|
|
),
|
|
title: Text(
|
|
sheetContext.l10n.channels_deleteChannel,
|
|
style: TextStyle(
|
|
color: Theme.of(sheetContext).colorScheme.error,
|
|
),
|
|
),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (parentContext.mounted) {
|
|
_confirmDeleteChannel(
|
|
parentContext,
|
|
connector,
|
|
channelMessageStore,
|
|
channel,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
if (index == 1) return;
|
|
switch (index) {
|
|
case 0:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
case 2:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _disconnect(BuildContext context) async {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
await showDisconnectDialog(context, connector);
|
|
}
|
|
|
|
Widget _buildFilterButton(UiViewStateService viewState) {
|
|
return SortFilterMenu<ChannelSortOption>(
|
|
tooltip: context.l10n.listFilter_tooltip,
|
|
sections: [
|
|
SortFilterMenuSection<ChannelSortOption>(
|
|
title: context.l10n.channels_sortBy,
|
|
options: [
|
|
SortFilterMenuOption<ChannelSortOption>(
|
|
value: ChannelSortOption.manual,
|
|
label: context.l10n.channels_sortManual,
|
|
checked: viewState.channelsSortOption == ChannelSortOption.manual,
|
|
),
|
|
SortFilterMenuOption<ChannelSortOption>(
|
|
value: ChannelSortOption.name,
|
|
label: context.l10n.channels_sortAZ,
|
|
checked: viewState.channelsSortOption == ChannelSortOption.name,
|
|
),
|
|
SortFilterMenuOption<ChannelSortOption>(
|
|
value: ChannelSortOption.latestMessages,
|
|
label: context.l10n.channels_sortLatestMessages,
|
|
checked:
|
|
viewState.channelsSortOption ==
|
|
ChannelSortOption.latestMessages,
|
|
),
|
|
SortFilterMenuOption<ChannelSortOption>(
|
|
value: ChannelSortOption.unread,
|
|
label: context.l10n.channels_sortUnread,
|
|
checked: viewState.channelsSortOption == ChannelSortOption.unread,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
onSelected: (sortOption) {
|
|
viewState.setChannelsSortOption(sortOption);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Channel> _filterAndSortChannels(
|
|
List<Channel> channels,
|
|
MeshCoreConnector connector,
|
|
UiViewStateService viewState,
|
|
) {
|
|
var filtered = channels.where((channel) {
|
|
if (viewState.channelsSearchText.isEmpty) return true;
|
|
final label = _normalizeChannelName(channel);
|
|
return label.toLowerCase().contains(
|
|
viewState.channelsSearchText.toLowerCase(),
|
|
);
|
|
}).toList();
|
|
|
|
int compareByName(Channel a, Channel b) {
|
|
final nameA = _normalizeChannelName(a);
|
|
final nameB = _normalizeChannelName(b);
|
|
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
|
|
}
|
|
|
|
switch (viewState.channelsSortOption) {
|
|
case ChannelSortOption.manual:
|
|
break;
|
|
case ChannelSortOption.latestMessages:
|
|
filtered.sort((a, b) {
|
|
final aMessages = connector.getChannelMessages(a);
|
|
final bMessages = connector.getChannelMessages(b);
|
|
final aLast = aMessages.isEmpty
|
|
? DateTime(1970)
|
|
: aMessages.last.timestamp;
|
|
final bLast = bMessages.isEmpty
|
|
? DateTime(1970)
|
|
: bMessages.last.timestamp;
|
|
final timeCompare = bLast.compareTo(aLast);
|
|
if (timeCompare != 0) return timeCompare;
|
|
return compareByName(a, b);
|
|
});
|
|
break;
|
|
case ChannelSortOption.unread:
|
|
filtered.sort((a, b) {
|
|
final aUnread = connector.getUnreadCountForChannel(a);
|
|
final bUnread = connector.getUnreadCountForChannel(b);
|
|
final unreadCompare = bUnread.compareTo(aUnread);
|
|
if (unreadCompare != 0) return unreadCompare;
|
|
return compareByName(a, b);
|
|
});
|
|
break;
|
|
case ChannelSortOption.name:
|
|
filtered.sort(compareByName);
|
|
break;
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
String _normalizeChannelName(Channel channel) {
|
|
if (channel.name.isEmpty) {
|
|
return 'Channel ${channel.index}'; // Fallback for sorting
|
|
}
|
|
final trimmed = channel.name.trim();
|
|
if (trimmed.startsWith('#') && trimmed.length > 1) {
|
|
return trimmed.substring(1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
void _showAddChannelDialog(BuildContext context) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final nextIndex = _findNextAvailableIndex(
|
|
connector.channels,
|
|
connector.maxChannels,
|
|
);
|
|
final hasPublicChannel = connector.channels.any((c) => c.isPublicChannel);
|
|
int? selectedOption;
|
|
final nameController = TextEditingController();
|
|
final pskController = TextEditingController();
|
|
final hashtagController = TextEditingController();
|
|
bool addPublicChannel = true;
|
|
bool isRegularHashtag = true;
|
|
Community? selectedCommunity;
|
|
|
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
|
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) {
|
|
Widget buildOptionCard({
|
|
required int optionIndex,
|
|
required IconData icon,
|
|
required String title,
|
|
required String subtitle,
|
|
bool enabled = true,
|
|
}) {
|
|
final isSelected = selectedOption == optionIndex;
|
|
final cardScheme = Theme.of(sheetContext).colorScheme;
|
|
return MeshCard(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
borderColor: isSelected && enabled ? MeshPalette.blueLine : null,
|
|
color: isSelected && enabled ? MeshPalette.blueBg : null,
|
|
onTap: enabled
|
|
? () {
|
|
setSheetState(() {
|
|
selectedOption = optionIndex;
|
|
nameController.clear();
|
|
pskController.clear();
|
|
hashtagController.clear();
|
|
});
|
|
}
|
|
: null,
|
|
child: Row(
|
|
children: [
|
|
AvatarCircle(
|
|
name: title,
|
|
size: 38,
|
|
color: enabled
|
|
? (isSelected
|
|
? MeshPalette.blue
|
|
: cardScheme.onSurfaceVariant)
|
|
: cardScheme.outline,
|
|
icon: icon,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(sheetContext).textTheme.bodyMedium
|
|
?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
color: enabled ? null : cardScheme.outline,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: Theme.of(sheetContext).textTheme.bodySmall
|
|
?.copyWith(
|
|
color: enabled
|
|
? cardScheme.onSurfaceVariant
|
|
: cardScheme.outline,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (enabled)
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: isSelected
|
|
? MeshPalette.blue
|
|
: cardScheme.onSurfaceVariant,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? buildExpandedContent(
|
|
ChannelMessageStore channelMessageStore,
|
|
) {
|
|
switch (selectedOption) {
|
|
case 0: // Create Private Channel
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_channelName,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () async {
|
|
final name = nameController.text.trim();
|
|
if (name.isEmpty) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
sheetContext
|
|
.l10n
|
|
.channels_enterChannelName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final psk = randomBytes(16);
|
|
Navigator.pop(sheetContext);
|
|
await connector.setChannel(
|
|
nextIndex,
|
|
name,
|
|
psk,
|
|
);
|
|
await channelMessageStore.clearChannelMessages(
|
|
nextIndex,
|
|
);
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelAdded(name),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_create),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
);
|
|
|
|
case 1: // Join Private Channel
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_channelName,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: TextField(
|
|
controller: pskController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_pskHex,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
final name = nameController.text.trim();
|
|
final pskHex = pskController.text.trim();
|
|
if (name.isEmpty) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
sheetContext
|
|
.l10n
|
|
.channels_enterChannelName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Uint8List psk;
|
|
try {
|
|
psk = Channel.parsePskHex(pskHex);
|
|
} on FormatException {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
sheetContext.l10n.channels_pskMustBe32Hex,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.pop(sheetContext);
|
|
connector.setChannel(nextIndex, name, psk);
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelAdded(name),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_add),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
);
|
|
|
|
case 2: // Join Public Channel
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
final psk = Channel.parsePskHex(
|
|
Channel.publicChannelPsk,
|
|
);
|
|
Navigator.pop(sheetContext);
|
|
connector.setChannel(
|
|
nextIndex,
|
|
context.l10n.channels_public,
|
|
psk,
|
|
);
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_publicChannelAdded,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_add),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
case 3: // Join Hashtag Channel
|
|
return Column(
|
|
children: [
|
|
// Only show type selection if user has communities
|
|
if (_communities.isNotEmpty) ...[
|
|
RadioGroup<bool>(
|
|
groupValue: isRegularHashtag,
|
|
onChanged: (v) => setSheetState(() {
|
|
if (v == null) return;
|
|
isRegularHashtag = v;
|
|
if (isRegularHashtag) {
|
|
selectedCommunity = null;
|
|
} else if (selectedCommunity == null &&
|
|
_communities.isNotEmpty) {
|
|
selectedCommunity = _communities.first;
|
|
}
|
|
}),
|
|
child: Column(
|
|
children: [
|
|
RadioListTile<bool>(
|
|
value: true,
|
|
title: Text(
|
|
sheetContext.l10n.community_regularHashtag,
|
|
),
|
|
subtitle: Text(
|
|
sheetContext.l10n.community_regularHashtagDesc,
|
|
),
|
|
dense: true,
|
|
),
|
|
RadioListTile<bool>(
|
|
value: false,
|
|
title: Text(
|
|
sheetContext.l10n.community_communityHashtag,
|
|
),
|
|
subtitle: Text(
|
|
sheetContext
|
|
.l10n
|
|
.community_communityHashtagDesc,
|
|
),
|
|
dense: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
// Community dropdown (only if community hashtag selected)
|
|
if (!isRegularHashtag && _communities.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: DropdownButtonFormField<Community>(
|
|
initialValue: selectedCommunity,
|
|
items: _communities
|
|
.map(
|
|
(c) => DropdownMenuItem(
|
|
value: c,
|
|
child: Text(c.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (c) =>
|
|
setSheetState(() => selectedCommunity = c),
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
sheetContext.l10n.community_selectCommunity,
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.groups),
|
|
),
|
|
),
|
|
),
|
|
// Hashtag name input
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: TextField(
|
|
controller: hashtagController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_enterHashtag,
|
|
hintText: sheetContext.l10n.channels_hashtagHint,
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.tag),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
),
|
|
// Privacy hint for community hashtags
|
|
if (!isRegularHashtag)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
sheetContext.l10n.community_hashtagPrivacyHint,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(
|
|
sheetContext,
|
|
).colorScheme.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () async {
|
|
var hashtag = hashtagController.text.trim();
|
|
if (hashtag.isEmpty) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
sheetContext
|
|
.l10n
|
|
.channels_enterChannelName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Normalize hashtag name (remove leading # if present)
|
|
if (hashtag.startsWith('#')) {
|
|
hashtag = hashtag.substring(1);
|
|
}
|
|
final String channelName;
|
|
|
|
final Uint8List psk;
|
|
if (isRegularHashtag) {
|
|
channelName = '#$hashtag';
|
|
// Regular hashtag - public derivation using SHA256
|
|
psk = Channel.derivePskFromHashtag(hashtag);
|
|
} else {
|
|
// Community hashtag - HMAC derivation from community secret
|
|
if (selectedCommunity == null) {
|
|
showDismissibleSnackBar(
|
|
sheetContext,
|
|
content: Text(
|
|
sheetContext
|
|
.l10n
|
|
.community_selectCommunity,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
channelName =
|
|
'${selectedCommunity!.name} #$hashtag';
|
|
psk = selectedCommunity!
|
|
.deriveCommunityHashtagPsk(hashtag);
|
|
// Track in community's hashtag list
|
|
await _communityStore.addHashtagChannel(
|
|
selectedCommunity!.id,
|
|
hashtag,
|
|
);
|
|
_loadCommunities();
|
|
}
|
|
|
|
if (sheetContext.mounted) {
|
|
Navigator.pop(sheetContext);
|
|
}
|
|
connector.setChannel(
|
|
nextIndex,
|
|
channelName,
|
|
psk,
|
|
);
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelAdded(
|
|
channelName,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_add),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
case 4: // Scan Community QR
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: () async {
|
|
Navigator.pop(sheetContext);
|
|
if (context.mounted) {
|
|
final result = await Navigator.push<Community>(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
const CommunityQrScannerScreen(),
|
|
),
|
|
);
|
|
// Result handled by scanner screen
|
|
if (result != null && context.mounted) {
|
|
// Community was joined, refresh might be needed
|
|
}
|
|
}
|
|
},
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
label: Text(sheetContext.l10n.community_scanQr),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
case 5: // Create Community
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.community_name,
|
|
hintText: sheetContext.l10n.community_enterName,
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.groups),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
),
|
|
CheckboxListTile(
|
|
value: addPublicChannel,
|
|
onChanged: (value) {
|
|
setSheetState(() {
|
|
addPublicChannel = value ?? true;
|
|
});
|
|
},
|
|
title: Text(sheetContext.l10n.community_addPublicChannel),
|
|
subtitle: Text(
|
|
sheetContext.l10n.community_addPublicChannelHint,
|
|
),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () async {
|
|
final name = nameController.text.trim();
|
|
final publicLabel =
|
|
context.l10n.channels_public;
|
|
if (name.isEmpty) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
sheetContext.l10n.community_enterName,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Create community with random secret
|
|
final community = Community.create(
|
|
id: const Uuid().v4(),
|
|
name: name,
|
|
);
|
|
|
|
// Save to store
|
|
await _communityStore.addCommunity(community);
|
|
|
|
// Optionally add the community public channel to the device
|
|
if (addPublicChannel) {
|
|
final psk = community
|
|
.deriveCommunityPublicPsk();
|
|
final channelName =
|
|
'${community.name} $publicLabel';
|
|
connector.setChannel(
|
|
nextIndex,
|
|
channelName,
|
|
psk,
|
|
);
|
|
}
|
|
|
|
if (sheetContext.mounted) {
|
|
Navigator.pop(sheetContext);
|
|
}
|
|
|
|
// Refresh communities list
|
|
_loadCommunities();
|
|
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.community_created(name),
|
|
),
|
|
);
|
|
|
|
// Show QR code dialog
|
|
await QrCodeShareDialog.show(
|
|
context: context,
|
|
data: community.toQrJson(),
|
|
title: context.l10n.community_qrTitle,
|
|
instructions: context.l10n
|
|
.community_qrInstructions(name),
|
|
embeddedImage: Image.asset(
|
|
'assets/images/mesh-icon.png',
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_create),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.7,
|
|
minChildSize: 0.4,
|
|
maxChildSize: 0.95,
|
|
builder: (_, scrollController) => Column(
|
|
children: [
|
|
BottomSheetHeader(title: sheetContext.l10n.channels_addChannel),
|
|
Expanded(
|
|
child: ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.only(bottom: 24),
|
|
children: [
|
|
buildOptionCard(
|
|
optionIndex: 0,
|
|
icon: Icons.add,
|
|
title: sheetContext.l10n.channels_createPrivateChannel,
|
|
subtitle:
|
|
sheetContext.l10n.channels_createPrivateChannelDesc,
|
|
),
|
|
if (selectedOption == 0)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
buildOptionCard(
|
|
optionIndex: 1,
|
|
icon: Icons.lock,
|
|
title: sheetContext.l10n.channels_joinPrivateChannel,
|
|
subtitle:
|
|
sheetContext.l10n.channels_joinPrivateChannelDesc,
|
|
),
|
|
if (selectedOption == 1)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
if (!hasPublicChannel) ...[
|
|
buildOptionCard(
|
|
optionIndex: 2,
|
|
icon: Icons.public,
|
|
title: sheetContext.l10n.channels_joinPublicChannel,
|
|
subtitle:
|
|
sheetContext.l10n.channels_joinPublicChannelDesc,
|
|
),
|
|
if (selectedOption == 2)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
],
|
|
buildOptionCard(
|
|
optionIndex: 3,
|
|
icon: Icons.tag,
|
|
title: sheetContext.l10n.channels_joinHashtagChannel,
|
|
subtitle:
|
|
sheetContext.l10n.channels_joinHashtagChannelDesc,
|
|
),
|
|
if (selectedOption == 3)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
buildOptionCard(
|
|
optionIndex: 4,
|
|
icon: Icons.qr_code_scanner,
|
|
title: sheetContext.l10n.community_scanQr,
|
|
subtitle: sheetContext.l10n.community_join,
|
|
),
|
|
if (selectedOption == 4)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
buildOptionCard(
|
|
optionIndex: 5,
|
|
icon: Icons.groups,
|
|
title: sheetContext.l10n.community_create,
|
|
subtitle: sheetContext.l10n.community_createDesc,
|
|
),
|
|
if (selectedOption == 5)
|
|
buildExpandedContent(_channelMessageStore)!,
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEditChannelDialog(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
Channel channel,
|
|
) {
|
|
final appSettingsService = Provider.of<AppSettingsService>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final nameController = TextEditingController(text: channel.name);
|
|
final pskController = TextEditingController(text: channel.pskHex);
|
|
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
|
|
bool cyr2latEnabled = connector.isChannelCyr2LatEnabled(channel.index);
|
|
String? selectedCyr2LatProfileId = connector.getChannelCyr2LatProfileId(
|
|
channel.index,
|
|
);
|
|
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) => DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.65,
|
|
minChildSize: 0.4,
|
|
maxChildSize: 0.95,
|
|
builder: (_, scrollController) => Column(
|
|
children: [
|
|
BottomSheetHeader(
|
|
title: sheetContext.l10n.channels_editChannelTitle(
|
|
channel.index,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_channelName,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: pskController,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.channels_pskHex,
|
|
border: const OutlineInputBorder(),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.casino),
|
|
tooltip: sheetContext.l10n.channels_generateRandomPsk,
|
|
onPressed: () {
|
|
final bytes = randomBytes(16);
|
|
pskController.text = Channel.formatPskHex(bytes);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(sheetContext.l10n.channels_smazCompression),
|
|
value: smazEnabled,
|
|
onChanged: (value) => setSheetState(() {
|
|
smazEnabled = value;
|
|
if (smazEnabled) {
|
|
cyr2latEnabled = false;
|
|
}
|
|
}),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(
|
|
sheetContext.l10n.channels_cyr2latCompression,
|
|
),
|
|
subtitle: Text(
|
|
sheetContext.l10n.channels_cyr2latCompressionDscr,
|
|
),
|
|
value: cyr2latEnabled,
|
|
onChanged: (value) => setSheetState(() {
|
|
cyr2latEnabled = value;
|
|
if (cyr2latEnabled) {
|
|
smazEnabled = false;
|
|
}
|
|
}),
|
|
),
|
|
if (cyr2latEnabled) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 8),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: selectedCyr2LatProfileId,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext
|
|
.l10n
|
|
.channels_cyr2latSettingsSubheading,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: appSettingsService.settings.cyr2latProfiles
|
|
.map((profile) {
|
|
return DropdownMenuItem(
|
|
value: profile.id,
|
|
child: Text(profile.name),
|
|
);
|
|
})
|
|
.toList(),
|
|
onChanged: (value) => setSheetState(() {
|
|
selectedCyr2LatProfileId = value;
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => Navigator.pop(sheetContext),
|
|
child: Text(sheetContext.l10n.common_cancel),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () async {
|
|
final name = nameController.text.trim();
|
|
final pskHex = pskController.text.trim();
|
|
|
|
Uint8List psk;
|
|
try {
|
|
psk = Channel.parsePskHex(pskHex);
|
|
} on FormatException {
|
|
showDismissibleSnackBar(
|
|
sheetContext,
|
|
content: Text(
|
|
sheetContext.l10n.channels_pskMustBe32Hex,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(sheetContext);
|
|
try {
|
|
await connector.setChannel(
|
|
channel.index,
|
|
name,
|
|
psk,
|
|
);
|
|
await connector.setChannelSmazEnabled(
|
|
channel.index,
|
|
smazEnabled,
|
|
);
|
|
await connector.setChannelCyr2LatEnabled(
|
|
channel.index,
|
|
cyr2latEnabled,
|
|
);
|
|
await connector.setChannelCyr2LatProfileId(
|
|
channel.index,
|
|
selectedCyr2LatProfileId,
|
|
);
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelUpdated(name),
|
|
),
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint(st.toString());
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelUpdateFailed('$e'),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(sheetContext.l10n.common_save),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDeleteChannel(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
ChannelMessageStore channelMessageStore,
|
|
Channel channel,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(dialogContext.l10n.channels_deleteChannel),
|
|
content: Text(
|
|
dialogContext.l10n.channels_deleteChannelConfirm(channel.name),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(dialogContext);
|
|
try {
|
|
await connector.deleteChannel(channel.index);
|
|
|
|
await channelMessageStore.clearChannelMessages(channel.index);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelDeleted(channel.name),
|
|
),
|
|
);
|
|
} catch (e, st) {
|
|
if (!context.mounted) return;
|
|
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
context.l10n.channels_channelDeleteFailed(channel.name),
|
|
),
|
|
);
|
|
|
|
// Preserve existing logging (if it was there)
|
|
debugPrint('Failed to delete channel: $e\n$st');
|
|
}
|
|
},
|
|
child: Text(
|
|
dialogContext.l10n.common_delete,
|
|
style: TextStyle(
|
|
color: Theme.of(dialogContext).colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
|
|
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
|
|
connector.setChannel(0, context.l10n.channels_public, psk);
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.channels_publicChannelAdded),
|
|
);
|
|
}
|
|
|
|
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
|
|
final usedIndices = channels.map((c) => c.index).toSet();
|
|
for (int i = 0; i < maxChannels; i++) {
|
|
if (!usedIndices.contains(i)) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void _showManageCommunitiesDialog(BuildContext context) {
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) => DraggableScrollableSheet(
|
|
initialChildSize: 0.5,
|
|
minChildSize: 0.3,
|
|
maxChildSize: 0.9,
|
|
expand: false,
|
|
builder: (_, scrollController) => Column(
|
|
children: [
|
|
BottomSheetHeader(
|
|
title: sheetContext.l10n.community_manageCommunities,
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: _communities.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.groups_outlined,
|
|
size: 64,
|
|
color: Theme.of(sheetContext)
|
|
.colorScheme
|
|
.onSurfaceVariant
|
|
.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
sheetContext.l10n.community_noCommunities,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Theme.of(
|
|
sheetContext,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
sheetContext.l10n.community_scanOrCreate,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Theme.of(sheetContext)
|
|
.colorScheme
|
|
.onSurfaceVariant
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
controller: scrollController,
|
|
itemCount: _communities.length,
|
|
itemBuilder: (context, index) {
|
|
final community = _communities[index];
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: MeshPalette.magentaBg,
|
|
child: const Icon(
|
|
Icons.groups,
|
|
color: MeshPalette.magenta,
|
|
),
|
|
),
|
|
title: Text(community.name),
|
|
subtitle: Text(
|
|
context.l10n.channels_communityShortId(
|
|
community.shortCommunityId,
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
trailing: PopupMenuButton<String>(
|
|
onSelected: (value) {
|
|
Navigator.pop(sheetContext);
|
|
// Use the screen's context: the sheet item's
|
|
// context is deactivated once the sheet pops.
|
|
if (value == 'share') {
|
|
_showCommunityQrDialog(this.context, community);
|
|
} else if (value == 'leave') {
|
|
_confirmLeaveCommunity(this.context, community);
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'share',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.qr_code),
|
|
const SizedBox(width: 12),
|
|
Text(context.l10n.community_showQr),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'leave',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.exit_to_app,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
context.l10n.community_delete,
|
|
style: TextStyle(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_showCommunityQrDialog(context, community);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCommunityQrDialog(BuildContext context, Community community) {
|
|
QrCodeShareDialog.show(
|
|
context: context,
|
|
data: community.toQrJson(),
|
|
title: context.l10n.community_qrTitle,
|
|
instructions: context.l10n.community_qrInstructions(community.name),
|
|
embeddedImage: Image.asset(
|
|
'assets/images/mesh-icon.png',
|
|
width: 40,
|
|
height: 40,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmLeaveCommunity(BuildContext context, Community community) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
|
|
// Find all channels that belong to this community
|
|
List<Channel> communityChannels = [];
|
|
final publicPskHex = Channel.formatPskHex(
|
|
community.deriveCommunityPublicPsk(),
|
|
);
|
|
|
|
for (final channel in connector.channels) {
|
|
// Check if it's the public channel
|
|
if (channel.pskHex == publicPskHex) {
|
|
communityChannels.add(channel);
|
|
continue;
|
|
}
|
|
// Check if it's a hashtag channel
|
|
for (final hashtag in community.hashtagChannels) {
|
|
final hashtagPskHex = Channel.formatPskHex(
|
|
community.deriveCommunityHashtagPsk(hashtag),
|
|
);
|
|
if (channel.pskHex == hashtagPskHex) {
|
|
communityChannels.add(channel);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
final channelCount = communityChannels.length;
|
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(dialogContext.l10n.community_delete),
|
|
content: Text(
|
|
channelCount > 0
|
|
? '${dialogContext.l10n.community_deleteConfirm(community.name)}\n\n${dialogContext.l10n.community_deleteChannelsWarning(channelCount)}'
|
|
: dialogContext.l10n.community_deleteConfirm(community.name),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(dialogContext.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(dialogContext);
|
|
|
|
// Delete all community channels from the device
|
|
for (final channel in communityChannels) {
|
|
await connector.deleteChannel(channel.index);
|
|
}
|
|
|
|
// Remove community from store
|
|
await _communityStore.removeCommunity(community.id);
|
|
_loadCommunities();
|
|
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.community_deleted(community.name)),
|
|
);
|
|
}
|
|
},
|
|
child: Text(
|
|
dialogContext.l10n.community_delete,
|
|
style: TextStyle(
|
|
color: Theme.of(dialogContext).colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|