mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-01 08:05:55 +10:00
feat: Add community management features with QR code scanning
- Implement Community model for managing community data, including secret handling and PSK derivation. - Create CommunityQrScannerScreen for scanning and joining communities via QR codes. - Develop CommunityStore for persisting community data using SharedPreferences. - Introduce QrCodeDisplay widget for displaying QR codes with customizable options. - Add QrScannerWidget for reusable QR code scanning functionality with validation and controls.
This commit is contained in:
@@ -4,19 +4,25 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/battery_indicator.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/qr_code_display.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channel_chat_screen.dart';
|
||||
import 'community_qr_scanner_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
@@ -43,17 +49,59 @@ class ChannelsScreen extends StatefulWidget {
|
||||
class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final CommunityStore _communityStore = CommunityStore();
|
||||
String _searchQuery = '';
|
||||
Timer? _searchDebounce;
|
||||
ChannelSortOption _sortOption = ChannelSortOption.manual;
|
||||
List<Community> _communities = [];
|
||||
|
||||
// Cache of PSK hex -> Community for quick lookup
|
||||
final Map<String, Community> _pskToCommunity = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MeshCoreConnector>().getChannels();
|
||||
_loadCommunities();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadCommunities() async {
|
||||
final communities = await _communityStore.loadCommunities();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_communities = communities;
|
||||
_buildPskCommunityMap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _buildPskCommunityMap() {
|
||||
_pskToCommunity.clear();
|
||||
for (final community in _communities) {
|
||||
// Map the community public channel PSK
|
||||
final publicPsk = community.deriveCommunityPublicPsk();
|
||||
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
|
||||
|
||||
// Map all known hashtag channel PSKs
|
||||
for (final hashtag in community.hashtagChannels) {
|
||||
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
|
||||
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the community this channel belongs to, or null if not a community channel
|
||||
Community? _getCommunityForChannel(Channel channel) {
|
||||
return _pskToCommunity[channel.pskHex];
|
||||
}
|
||||
|
||||
/// Returns true if this is the community's public channel
|
||||
bool _isCommunityPublicChannel(Channel channel, Community community) {
|
||||
final publicPsk = community.deriveCommunityPublicPsk();
|
||||
return channel.pskHex == Channel.formatPskHex(publicPsk);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -82,6 +130,12 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
if (_communities.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.groups),
|
||||
tooltip: context.l10n.community_manageCommunities,
|
||||
onPressed: () => _showManageCommunitiesDialog(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: context.l10n.common_disconnect,
|
||||
@@ -268,6 +322,44 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
}
|
||||
) {
|
||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||
final community = _getCommunityForChannel(channel);
|
||||
final isCommunityChannel = community != null;
|
||||
final isCommunityPublic = isCommunityChannel && _isCommunityPublicChannel(channel, community);
|
||||
|
||||
// Determine icon and colors based on channel type
|
||||
IconData icon;
|
||||
Color iconColor;
|
||||
Color bgColor;
|
||||
String subtitle;
|
||||
|
||||
if (isCommunityChannel) {
|
||||
// Community channel styling
|
||||
iconColor = Colors.purple;
|
||||
bgColor = Colors.purple.withValues(alpha: 0.2);
|
||||
if (isCommunityPublic) {
|
||||
icon = Icons.groups;
|
||||
subtitle = '${context.l10n.community_publicChannel} • ${community.name}';
|
||||
} else {
|
||||
icon = Icons.tag;
|
||||
subtitle = '${context.l10n.community_hashtagChannel} • ${community.name}';
|
||||
}
|
||||
} else if (channel.isPublicChannel) {
|
||||
icon = Icons.public;
|
||||
iconColor = Colors.green;
|
||||
bgColor = Colors.green.withValues(alpha: 0.2);
|
||||
subtitle = context.l10n.channels_publicChannel;
|
||||
} else if (channel.name.startsWith('#')) {
|
||||
icon = Icons.tag;
|
||||
iconColor = Colors.blue;
|
||||
bgColor = Colors.blue.withValues(alpha: 0.2);
|
||||
subtitle = context.l10n.channels_hashtagChannel;
|
||||
} else {
|
||||
icon = Icons.lock;
|
||||
iconColor = Colors.blue;
|
||||
bgColor = Colors.blue.withValues(alpha: 0.2);
|
||||
subtitle = context.l10n.channels_privateChannel;
|
||||
}
|
||||
|
||||
return Card(
|
||||
key: ValueKey('channel_${channel.index}'),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
@@ -276,29 +368,44 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: channel.isPublicChannel
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
: Colors.blue.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
channel.isPublicChannel
|
||||
? Icons.public
|
||||
: channel.name.startsWith('#')
|
||||
? Icons.tag
|
||||
: Icons.lock,
|
||||
color: channel.isPublicChannel ? Colors.green : Colors.blue,
|
||||
),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: bgColor,
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
if (isCommunityChannel)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).cardColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.people,
|
||||
size: 8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
channel.name.isEmpty ? context.l10n.channels_channelIndex(channel.index) : channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
channel.name.startsWith('#')
|
||||
? context.l10n.channels_hashtagChannel
|
||||
: channel.isPublicChannel
|
||||
? context.l10n.channels_publicChannel
|
||||
: context.l10n.channels_privateChannel,
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -521,6 +628,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
final nameController = TextEditingController();
|
||||
final pskController = TextEditingController();
|
||||
final hashtagController = TextEditingController();
|
||||
bool addPublicChannel = true;
|
||||
bool isRegularHashtag = true;
|
||||
Community? selectedCommunity;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -713,6 +823,55 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
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) => setDialogState(() {
|
||||
if (v != null) {
|
||||
isRegularHashtag = v;
|
||||
if (isRegularHashtag) {
|
||||
selectedCommunity = null;
|
||||
} else if (selectedCommunity == null && _communities.isNotEmpty) {
|
||||
selectedCommunity = _communities.first;
|
||||
}
|
||||
}
|
||||
}),
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<bool>(
|
||||
value: true,
|
||||
title: Text(dialogContext.l10n.community_regularHashtag),
|
||||
subtitle: Text(dialogContext.l10n.community_regularHashtagDesc),
|
||||
dense: true,
|
||||
),
|
||||
RadioListTile<bool>(
|
||||
value: false,
|
||||
title: Text(dialogContext.l10n.community_communityHashtag),
|
||||
subtitle: Text(dialogContext.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: DropdownMenu<Community>(
|
||||
initialSelection: selectedCommunity,
|
||||
dropdownMenuEntries: _communities.map((c) => DropdownMenuEntry(
|
||||
value: c,
|
||||
label: c.name,
|
||||
)).toList(),
|
||||
onSelected: (c) => setDialogState(() => selectedCommunity = c),
|
||||
label: Text(dialogContext.l10n.community_selectCommunity),
|
||||
leadingIcon: const Icon(Icons.groups),
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
// Hashtag name input
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
@@ -726,13 +885,26 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
maxLength: 31,
|
||||
),
|
||||
),
|
||||
// Privacy hint for community hashtags
|
||||
if (!isRegularHashtag)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
dialogContext.l10n.community_hashtagPrivacyHint,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
var hashtag = hashtagController.text.trim();
|
||||
if (hashtag.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
@@ -740,14 +912,38 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Normalize hashtag name
|
||||
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
|
||||
final psk = Channel.derivePskFromHashtag(hashtag);
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, name, psk);
|
||||
|
||||
// Normalize hashtag name (remove leading # if present)
|
||||
if (hashtag.startsWith('#')) {
|
||||
hashtag = hashtag.substring(1);
|
||||
}
|
||||
final channelName = '#$hashtag';
|
||||
|
||||
final Uint8List psk;
|
||||
if (isRegularHashtag) {
|
||||
// Regular hashtag - public derivation using SHA256
|
||||
psk = Channel.derivePskFromHashtag(hashtag);
|
||||
} else {
|
||||
// Community hashtag - HMAC derivation from community secret
|
||||
if (selectedCommunity == null) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.l10n.community_selectCommunity)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
psk = selectedCommunity!.deriveCommunityHashtagPsk(hashtag);
|
||||
// Track in community's hashtag list
|
||||
await _communityStore.addHashtagChannel(selectedCommunity!.id, hashtag);
|
||||
_loadCommunities();
|
||||
}
|
||||
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
connector.setChannel(nextIndex, channelName, psk);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
|
||||
SnackBar(content: Text(context.l10n.channels_channelAdded(channelName))),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -760,6 +956,126 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
],
|
||||
);
|
||||
|
||||
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(dialogContext);
|
||||
if (context.mounted) {
|
||||
await Navigator.push<Community>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CommunityQrScannerScreen(),
|
||||
),
|
||||
);
|
||||
// Refresh communities list when returning from scanner
|
||||
if (context.mounted) {
|
||||
_loadCommunities();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: Text(dialogContext.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: dialogContext.l10n.community_name,
|
||||
hintText: dialogContext.l10n.community_enterName,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.groups),
|
||||
),
|
||||
maxLength: 31,
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: addPublicChannel,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
addPublicChannel = value ?? true;
|
||||
});
|
||||
},
|
||||
title: Text(dialogContext.l10n.community_addPublicChannel),
|
||||
subtitle: Text(dialogContext.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();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(dialogContext).showSnackBar(
|
||||
SnackBar(content: Text(dialogContext.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} Public';
|
||||
connector.setChannel(nextIndex, channelName, psk);
|
||||
}
|
||||
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
|
||||
// Refresh communities list
|
||||
_loadCommunities();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(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(dialogContext.l10n.common_create),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -810,11 +1126,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 4,
|
||||
icon: Icons.qr_code,
|
||||
title: dialogContext.l10n.channels_scanQrCode,
|
||||
subtitle: dialogContext.l10n.channels_scanQrCodeComingSoon,
|
||||
enabled: false,
|
||||
icon: Icons.qr_code_scanner,
|
||||
title: dialogContext.l10n.community_scanQr,
|
||||
subtitle: dialogContext.l10n.community_join,
|
||||
),
|
||||
if (selectedOption == 4) buildExpandedContent()!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 5,
|
||||
icon: Icons.groups,
|
||||
title: dialogContext.l10n.community_create,
|
||||
subtitle: dialogContext.l10n.community_createDesc,
|
||||
),
|
||||
if (selectedOption == 5) buildExpandedContent()!,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -967,4 +1291,360 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _showManageCommunitiesDialog(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.5,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (_, scrollController) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.groups, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.community_manageCommunities,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _communities.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.groups_outlined, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.community_noCommunities,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.community_scanOrCreate,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: _communities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final community = _communities[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.withValues(alpha: 0.2),
|
||||
child: const Icon(Icons.groups, color: Colors.purple),
|
||||
),
|
||||
title: Text(community.name),
|
||||
subtitle: Text(
|
||||
'ID: ${community.shortCommunityId}...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
Navigator.pop(sheetContext);
|
||||
if (value == 'share') {
|
||||
_showCommunityQrDialog(context, community);
|
||||
} else if (value == 'regenerate') {
|
||||
_regenerateCommunitySecret(context, community);
|
||||
} else if (value == 'update') {
|
||||
_updateCommunitySecret(context, community);
|
||||
} else if (value == 'leave') {
|
||||
_confirmLeaveCommunity(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: 'regenerate',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.refresh),
|
||||
const SizedBox(width: 12),
|
||||
Text(context.l10n.community_regenerateSecret),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'update',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code_scanner),
|
||||
const SizedBox(width: 12),
|
||||
Text(context.l10n.community_updateSecret),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'leave',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.exit_to_app, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
context.l10n.community_delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/// Regenerate the community secret and update all associated channels
|
||||
void _regenerateCommunitySecret(BuildContext context, Community community) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(dialogContext.l10n.community_regenerateSecret),
|
||||
content: Text(dialogContext.l10n.community_regenerateSecretConfirm(community.name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(dialogContext.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final newCommunity = community.withRegeneratedSecret();
|
||||
|
||||
// Update channel PSKs
|
||||
await _updateCommunityChannelPsks(connector, community, newCommunity);
|
||||
|
||||
// Save updated community
|
||||
await _communityStore.updateCommunity(newCommunity);
|
||||
_loadCommunities();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.community_secretRegenerated(community.name))),
|
||||
);
|
||||
|
||||
// Show the new QR code
|
||||
_showCommunityQrDialog(context, newCommunity);
|
||||
}
|
||||
},
|
||||
child: Text(dialogContext.l10n.community_regenerate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update community secret from a scanned QR code
|
||||
void _updateCommunitySecret(BuildContext context, Community community) async {
|
||||
final result = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _CommunitySecretScannerScreen(
|
||||
communityName: community.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final newSecret = Community.extractSecretFromQrData(result);
|
||||
if (newSecret == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.community_invalidQrCode)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final newCommunity = community.withNewSecret(newSecret);
|
||||
|
||||
// Update channel PSKs
|
||||
await _updateCommunityChannelPsks(connector, community, newCommunity);
|
||||
|
||||
// Save updated community
|
||||
await _communityStore.updateCommunity(newCommunity);
|
||||
_loadCommunities();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.community_secretUpdated(community.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update PSKs for all channels belonging to a community
|
||||
Future<void> _updateCommunityChannelPsks(
|
||||
MeshCoreConnector connector,
|
||||
Community oldCommunity,
|
||||
Community newCommunity,
|
||||
) async {
|
||||
// Find and update the public channel
|
||||
final oldPublicPskHex = Channel.formatPskHex(oldCommunity.deriveCommunityPublicPsk());
|
||||
final newPublicPsk = newCommunity.deriveCommunityPublicPsk();
|
||||
|
||||
for (final channel in connector.channels) {
|
||||
if (channel.pskHex == oldPublicPskHex) {
|
||||
await connector.setChannel(channel.index, channel.name, newPublicPsk);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and update hashtag channels
|
||||
for (final hashtag in oldCommunity.hashtagChannels) {
|
||||
final oldHashtagPskHex = Channel.formatPskHex(oldCommunity.deriveCommunityHashtagPsk(hashtag));
|
||||
final newHashtagPsk = newCommunity.deriveCommunityHashtagPsk(hashtag);
|
||||
|
||||
for (final channel in connector.channels) {
|
||||
if (channel.pskHex == oldHashtagPskHex) {
|
||||
await connector.setChannel(channel.index, channel.name, newHashtagPsk);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.community_deleted(community.name))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
dialogContext.l10n.community_delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple scanner screen for updating community secret
|
||||
class _CommunitySecretScannerScreen extends StatelessWidget {
|
||||
final String communityName;
|
||||
|
||||
const _CommunitySecretScannerScreen({required this.communityName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.community_updateSecret),
|
||||
),
|
||||
body: QrScannerWidget(
|
||||
onScanned: (data) {
|
||||
Navigator.pop(context, data);
|
||||
},
|
||||
validator: (data) => Community.isValidQrData(data),
|
||||
onValidationFailed: (data) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.community_invalidQrCode)),
|
||||
);
|
||||
},
|
||||
instructions: context.l10n.community_scanToUpdateSecret(communityName),
|
||||
overlay: const ScannerCornerOverlay(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
|
||||
/// Screen for scanning community QR codes to join communities.
|
||||
///
|
||||
/// After successful scan, the user can:
|
||||
/// 1. Join the community (saves to local storage)
|
||||
/// 2. Optionally add the Community Public Channel to the device
|
||||
class CommunityQrScannerScreen extends StatefulWidget {
|
||||
const CommunityQrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CommunityQrScannerScreen> createState() =>
|
||||
_CommunityQrScannerScreenState();
|
||||
}
|
||||
|
||||
class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
final CommunityStore _communityStore = CommunityStore();
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.community_scanQr),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isProcessing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: QrScannerWidget(
|
||||
onScanned: (data) => _handleScannedData(context, data),
|
||||
validator: Community.isValidQrData,
|
||||
onValidationFailed: (_) => _showInvalidQrError(context),
|
||||
instructions: context.l10n.community_scanInstructions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleScannedData(BuildContext context, String data) async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Parse the community data
|
||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||
|
||||
// Check if this community already exists
|
||||
final existing = await _communityStore.findByCommunityId(
|
||||
community.communityId,
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
if (context.mounted) {
|
||||
_showAlreadyMemberDialog(context, existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
if (context.mounted) {
|
||||
await _showJoinConfirmationDialog(context, community);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidQrError(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.community_alreadyMember),
|
||||
content: Text(
|
||||
context.l10n.community_alreadyMemberMessage(community.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.common_ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showJoinConfirmationDialog(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
) async {
|
||||
bool addPublicChannel = true;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: Text(context.l10n.community_joinTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.community_joinConfirmation(community.name)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.groups,
|
||||
color: Theme.of(dialogContext).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
community.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'ID: ${community.shortCommunityId}...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: addPublicChannel,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
addPublicChannel = value ?? true;
|
||||
});
|
||||
},
|
||||
title: Text(context.l10n.community_addPublicChannel),
|
||||
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(context.l10n.community_join),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && context.mounted) {
|
||||
await _joinCommunity(context, community, addPublicChannel);
|
||||
} else if (context.mounted) {
|
||||
// User cancelled - go back
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinCommunity(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
bool addPublicChannel,
|
||||
) async {
|
||||
// Save community to local storage
|
||||
await _communityStore.addCommunity(community);
|
||||
|
||||
// Optionally add the community public channel to the device
|
||||
if (addPublicChannel && context.mounted) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nextIndex = _findNextAvailableChannelIndex(connector);
|
||||
|
||||
if (nextIndex != null) {
|
||||
final psk = community.deriveCommunityPublicPsk();
|
||||
final channelName = '${community.name} Public';
|
||||
connector.setChannel(nextIndex, channelName, psk);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_joined(community.name)),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Return to previous screen
|
||||
Navigator.pop(context, community);
|
||||
}
|
||||
}
|
||||
|
||||
int? _findNextAvailableChannelIndex(MeshCoreConnector connector) {
|
||||
final usedIndices = connector.channels.map((c) => c.index).toSet();
|
||||
for (int i = 0; i < connector.maxChannels; i++) {
|
||||
if (!usedIndices.contains(i)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user