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:
zjs81
2026-01-19 20:56:07 -07:00
parent f790604d23
commit f4ec732de8
45 changed files with 5971 additions and 42 deletions
+709 -29
View File
@@ -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;
}
}