feat: Update project version to 19, add repeaters filter and theme support, and enhance contact list display

This commit is contained in:
Janez T
2025-10-16 21:43:19 +02:00
parent 58d8ef0dae
commit e74ebda54d
15 changed files with 312 additions and 95 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
@@ -529,7 +529,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
@@ -545,7 +545,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
@@ -676,7 +676,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -699,7 +699,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>18</string>
<string>19</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>

View File

@@ -5,17 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000203">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000227">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.287931">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.240778">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="31.633362">
<testcase classname="fastlane.lanes" name="2: build_app" time="91.431808">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="328.922766">
</testcase>

View File

@@ -1097,6 +1097,11 @@
"description": "Filter option for contacts"
},
"repeatersFilter": "Repeaters",
"@repeatersFilter": {
"description": "Filter option for repeaters"
},
"sarMarkers": "SAR Markers",
"@sarMarkers": {
"description": "SAR markers section header"

View File

@@ -371,6 +371,8 @@
"contactsFilter": "Kontakti",
"repeatersFilter": "Repetitori",
"sarMarkers": "SAR markeri",
"foundPerson": "Pronađena osoba",

View File

@@ -1215,6 +1215,12 @@ abstract class AppLocalizations {
/// **'Contacts'**
String get contactsFilter;
/// Filter option for repeaters
///
/// In en, this message translates to:
/// **'Repeaters'**
String get repeatersFilter;
/// SAR markers section header
///
/// In en, this message translates to:

View File

@@ -643,6 +643,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get contactsFilter => 'Contacts';
@override
String get repeatersFilter => 'Repeaters';
@override
String get sarMarkers => 'SAR Markers';

View File

@@ -643,6 +643,9 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get contactsFilter => 'Kontakti';
@override
String get repeatersFilter => 'Repetitori';
@override
String get sarMarkers => 'SAR markeri';

View File

@@ -643,6 +643,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get contactsFilter => 'Stiki';
@override
String get repeatersFilter => 'Ponavljalniki';
@override
String get sarMarkers => 'SAR označevalci';

View File

@@ -371,6 +371,8 @@
"contactsFilter": "Stiki",
"repeatersFilter": "Ponavljalniki",
"sarMarkers": "SAR označevalci",
"foundPerson": "Najdena oseba",

View File

@@ -772,6 +772,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.pop(context);
},
),
RadioListTile<AppThemeMode>(
title: Row(
children: [
const Text('SAR Navy Blue'),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: const Color(0xFF5C9FFF),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
],
),
subtitle: const Text('Professional/Operations Mode'),
value: AppThemeMode.sarNavyBlue,
groupValue: _selectedTheme,
onChanged: (value) {
_handleThemeChange(value);
Navigator.pop(context);
},
),
const Divider(),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.autoSystem),

View File

@@ -5,6 +5,7 @@ enum AppThemeMode {
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
@@ -181,6 +182,65 @@ class AppTheme {
);
}
// SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF5C9FFF),
width: 1,
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
),
),
);
}
// Get theme by mode
static ThemeData getTheme(AppThemeMode mode, Brightness systemBrightness) {
switch (mode) {
@@ -192,6 +252,8 @@ class AppTheme {
return sarRedTheme;
case AppThemeMode.sarGreen:
return sarGreenTheme;
case AppThemeMode.sarNavyBlue:
return sarNavyBlueTheme;
case AppThemeMode.system:
return systemBrightness == Brightness.dark ? darkTheme : lightTheme;
}
@@ -208,6 +270,8 @@ class AppTheme {
return 'SAR Red (Alert)';
case AppThemeMode.sarGreen:
return 'SAR Green (Safe)';
case AppThemeMode.sarNavyBlue:
return 'SAR Navy Blue (Ops)';
case AppThemeMode.system:
return 'Auto (System)';
}

View File

@@ -6,11 +6,14 @@ import '../../../models/contact.dart';
/// Contact list section for the compass dialog.
/// Shows all contacts with location sorted by distance with bearing information.
/// Splits contacts by type: Persons/Team, Repeaters, and Rooms.
class CompassContactList extends StatelessWidget {
final List<Contact> contacts;
final Position? position;
final double? heading;
final Contact? selectedContact;
final bool showContacts;
final bool showRepeaters;
final ValueChanged<Contact?> onContactTap;
const CompassContactList({
@@ -19,6 +22,8 @@ class CompassContactList extends StatelessWidget {
required this.position,
this.heading,
required this.selectedContact,
required this.showContacts,
required this.showRepeaters,
required this.onContactTap,
});
@@ -32,9 +37,16 @@ class CompassContactList extends StatelessWidget {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
// Calculate bearings and distances
final contactsWithBearing = contacts.map((contact) {
if (contact.displayLocation == null) return null;
final l10n = AppLocalizations.of(context)!;
// Split contacts by type
final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact
for (final contact in contacts) {
if (contact.displayLocation == null) continue;
final bearing = _calculateBearing(
position!.latitude,
@@ -50,101 +62,160 @@ class CompassContactList extends StatelessWidget {
contact.displayLocation!.longitude,
);
return {
final item = {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
}).whereType<Map<String, dynamic>>().toList();
// Sort by distance
contactsWithBearing.sort((a, b) =>
(a['distance'] as double).compareTo(b['distance'] as double));
if (contact.isRepeater) {
repeaters.add(item);
} else if (contact.isRoom) {
rooms.add(item);
} else {
persons.add(item);
}
}
// Sort each list by distance
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: Text(
l10n.nearbyContacts,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...contactsWithBearing.map((item) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
Icons.person,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
// Persons/Team section
if (showContacts && persons.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text(
l10n.teamMembers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}),
),
...persons.map((item) => _buildContactTile(
context,
item,
Icons.groups,
Theme.of(context).colorScheme.primary,
)),
],
// Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.repeaters,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...repeaters.map((item) => _buildContactTile(
context,
item,
Icons.router,
Colors.purple,
)),
],
// Rooms section
if (rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...rooms.map((item) => _buildContactTile(
context,
item,
Icons.meeting_room,
Colors.teal,
)),
],
],
);
}
Widget _buildContactTile(
BuildContext context,
Map<String, dynamic> item,
IconData defaultIcon,
Color iconColor,
) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
defaultIcon,
color: iconColor,
size: 24,
),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {

View File

@@ -5,10 +5,12 @@ import '../../../l10n/app_localizations.dart';
/// Allows filtering of contacts and SAR marker types.
class CompassFilters extends StatefulWidget {
final bool showContacts;
final bool showRepeaters;
final bool showFoundPerson;
final bool showFire;
final bool showStagingArea;
final ValueChanged<bool> onShowContactsChanged;
final ValueChanged<bool> onShowRepeatersChanged;
final ValueChanged<bool> onShowFoundPersonChanged;
final ValueChanged<bool> onShowFireChanged;
final ValueChanged<bool> onShowStagingAreaChanged;
@@ -17,10 +19,12 @@ class CompassFilters extends StatefulWidget {
const CompassFilters({
super.key,
required this.showContacts,
required this.showRepeaters,
required this.showFoundPerson,
required this.showFire,
required this.showStagingArea,
required this.onShowContactsChanged,
required this.onShowRepeatersChanged,
required this.onShowFoundPersonChanged,
required this.onShowFireChanged,
required this.onShowStagingAreaChanged,
@@ -62,6 +66,18 @@ class _CompassFiltersState extends State<CompassFilters> {
},
),
const SizedBox(height: 4),
// Repeaters filter
_CompactFilterItem(
icon: Icons.router,
color: Colors.purple,
label: l10n.repeatersFilter,
value: widget.showRepeaters,
onChanged: (value) {
widget.onShowRepeatersChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// SAR Markers section

View File

@@ -65,6 +65,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
// Visibility toggles
bool _showContacts = true;
bool _showRepeaters = false; // Hide repeaters by default
bool _showFoundPerson = true;
bool _showFire = true;
bool _showStagingArea = true;
@@ -308,6 +309,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
),
CompassFilters(
showContacts: _showContacts,
showRepeaters: _showRepeaters,
showFoundPerson: _showFoundPerson,
showFire: _showFire,
showStagingArea: _showStagingArea,
@@ -316,6 +318,11 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
_showContacts = value;
});
},
onShowRepeatersChanged: (value) {
setState(() {
_showRepeaters = value;
});
},
onShowFoundPersonChanged: (value) {
setState(() {
_showFoundPerson = value;
@@ -334,6 +341,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
onShowAll: () {
setState(() {
_showContacts = true;
_showRepeaters = true;
_showFoundPerson = true;
_showFire = true;
_showStagingArea = true;
@@ -363,7 +371,10 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
? [_selectedContact!]
: (_selectedSarMarker != null
? []
: (_showContacts ? widget.contacts : [])),
: widget.contacts.where((c) =>
(_showContacts && !c.isRepeater && !c.isRoom) ||
(_showRepeaters && c.isRepeater)
).toList()),
sarMarkers: _selectedSarMarker != null
? [_selectedSarMarker!]
: (_selectedContact != null
@@ -381,12 +392,14 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
_buildSelectedItemDetail(context, heading, position),
const SizedBox(height: 12),
// Contacts list
if (_showContacts && widget.contacts.isNotEmpty)
if (widget.contacts.isNotEmpty)
CompassContactList(
contacts: widget.contacts,
position: position,
heading: heading,
selectedContact: _selectedContact,
showContacts: _showContacts,
showRepeaters: _showRepeaters,
onContactTap: (contact) {
setState(() {
_selectedContact = contact;