Skip to content
37 changes: 36 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1895,6 +1895,7 @@ class Buddies extends Table {
TextColumn get phone => text().nullable()();
TextColumn get photoPath => text().nullable()();
TextColumn get notes => text().withDefault(const Constant(''))();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();

Expand Down Expand Up @@ -3183,7 +3184,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 166;
static const int currentSchemaVersion = 168;

/// The oldest schema whose reader can apply this build's sync payloads
/// without loss or misinterpretation (the compatibility floor).
Expand Down Expand Up @@ -3487,6 +3488,13 @@ class AppDatabase extends _$AppDatabase {
// reverse-geocoded country/region/town/body of water (issue #1187).
// Renumbered from 162, which #731 landed past while this branch was open.
166,
// v167 is likewise absent: it is claimed by issue #1269 (PR #1276) on a
// branch that is still open.
// v168 (issue #638): buddies.is_favorite, so frequently-dived buddies can
// be pinned to the top of the "Add buddy" picker regardless of sort.
// Renumbered from 161, which #1235 landed on main while this branch was
// open.
168,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
Expand Down Expand Up @@ -5082,6 +5090,21 @@ class AppDatabase extends _$AppDatabase {
}
}

/// Idempotent DDL for the v168 buddies.is_favorite column (issue #638),
/// letting frequently-dived buddies be pinned to the top of the "Add
/// buddy" picker regardless of sort. Self-guards on the table existing, and
/// defaults every pre-existing row to not-favorited.
Future<void> _assertBuddyFavoriteColumn() async {
final cols = await customSelect("PRAGMA table_info('buddies')").get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('is_favorite')) {
await customStatement(
'ALTER TABLE buddies ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0',
);
}
}

/// Idempotent DDL for the v159 dive_data_sources.time_offset_seconds
/// column (issue #1177). Same dual-call contract (onUpgrade + beforeOpen
/// backstop) as the other column-assert helpers. Nullable with no default,
Expand Down Expand Up @@ -8647,6 +8670,13 @@ class AppDatabase extends _$AppDatabase {
await _assertPlaceNameLanguageColumn();
}
if (from < 166) await reportProgress();
// v168 (issue #638): buddies.is_favorite, so frequently-dived buddies
// can be pinned to the top of the "Add buddy" picker regardless of
// sort.
if (from < 168) {
await _assertBuddyFavoriteColumn();
}
if (from < 168) await reportProgress();
},
beforeOpen: (details) async {
// Enable foreign keys
Expand Down Expand Up @@ -8851,6 +8881,11 @@ class AppDatabase extends _$AppDatabase {
// media row mapper reads it on every hydration.
await _assertMediaManualElapsedColumn();

// v168 backstop: re-assert buddies.is_favorite (issue #638). A
// database that arrives by restore or sync-adopt never runs
// onUpgrade, and every read of a buddy would throw without it.
await _assertBuddyFavoriteColumn();

// v145 backstop: re-assert the gps_tracks provenance and trim columns.
await _assertGpsTrackColumns();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class BuddyMergeRepository {
certificationAgency: null,
photoPath: row.photoPath,
notes: row.notes,
isFavorite: row.isFavorite,
createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt),
updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt),
);
Expand Down Expand Up @@ -444,6 +445,7 @@ class BuddyMergeRepository {
phone: Value(buddy.phone),
photoPath: Value(buddy.photoPath),
notes: Value(buddy.notes),
isFavorite: Value(buddy.isFavorite),
createdAt: Value(buddy.createdAt.millisecondsSinceEpoch),
updatedAt: Value(buddy.updatedAt.millisecondsSinceEpoch),
),
Expand Down Expand Up @@ -591,6 +593,7 @@ class BuddyMergeRepository {
phone: Value(buddy.phone),
photoPath: Value(buddy.photoPath),
notes: Value(buddy.notes),
isFavorite: Value(buddy.isFavorite),
updatedAt: Value(now),
),
);
Expand Down
107 changes: 103 additions & 4 deletions lib/features/buddies/data/repositories/buddy_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class BuddyRepository {
),
photoPath: row.data['photo_path'] as String?,
notes: (row.data['notes'] as String?) ?? '',
isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(
row.data['created_at'] as int,
),
Expand Down Expand Up @@ -175,6 +176,7 @@ class BuddyRepository {
phone: Value(buddy.phone),
photoPath: Value(buddy.photoPath),
notes: Value(buddy.notes),
isFavorite: Value(buddy.isFavorite),
createdAt: Value(now.millisecondsSinceEpoch),
updatedAt: Value(now.millisecondsSinceEpoch),
),
Expand Down Expand Up @@ -236,6 +238,7 @@ class BuddyRepository {
),
photoPath: row.data['photo_path'] as String?,
notes: (row.data['notes'] as String?) ?? '',
isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(
row.data['created_at'] as int,
),
Expand Down Expand Up @@ -283,6 +286,7 @@ class BuddyRepository {
phone: Value(buddy.phone),
photoPath: Value(buddy.photoPath),
notes: Value(buddy.notes),
isFavorite: Value(buddy.isFavorite),
updatedAt: Value(now),
),
);
Expand Down Expand Up @@ -375,6 +379,7 @@ class BuddyRepository {
),
photoPath: row.data['photo_path'] as String?,
notes: (row.data['notes'] as String?) ?? '',
isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(
row.data['created_at'] as int,
),
Expand Down Expand Up @@ -436,6 +441,7 @@ class BuddyRepository {
phone: b.phone,
photoPath: b.photoPath,
notes: b.notes,
isFavorite: b.isFavorite,
createdAt: DateTime.fromMillisecondsSinceEpoch(b.createdAt),
updatedAt: DateTime.fromMillisecondsSinceEpoch(b.updatedAt),
);
Expand Down Expand Up @@ -750,13 +756,35 @@ class BuddyRepository {
}
}

/// Get all buddies with their dive counts in a single efficient query
/// Get all buddies with their dive counts in a single efficient query.
///
/// [query] optionally filters by name/email/phone (case-insensitive), for
/// the "Add buddy" picker's search box, which needs dive counts too so it
/// can sort search results the same way as the unfiltered list.
Future<List<BuddyWithDiveCount>> getAllBuddiesWithDiveCount({
String? diverId,
String? query,
}) async {
try {
final diverFilter = diverId != null ? 'WHERE b.diver_id = ?' : '';
final variables = [if (diverId != null) Variable.withString(diverId)];
final conditions = <String>[
if (diverId != null) 'b.diver_id = ?',
if (query != null && query.isNotEmpty)
'(LOWER(b.name) LIKE ? OR LOWER(b.email) LIKE ? OR b.phone LIKE ?)',
];
final where = conditions.isEmpty
? ''
: 'WHERE ${conditions.join(' AND ')}';
final searchTerm = query != null && query.isNotEmpty
? '%${query.toLowerCase()}%'
: null;
final variables = [
if (diverId != null) Variable.withString(diverId),
if (searchTerm != null) ...[
Variable.withString(searchTerm),
Variable.withString(searchTerm),
Variable.withString(searchTerm),
],
];

final results = await _db.customSelect('''
SELECT b.*, COALESCE(dc.dive_count, 0) as dive_count
Expand All @@ -766,7 +794,7 @@ class BuddyRepository {
FROM dive_buddies
GROUP BY buddy_id
) dc ON b.id = dc.buddy_id
$diverFilter
$where
ORDER BY b.name ASC
''', variables: variables).get();

Expand All @@ -785,6 +813,7 @@ class BuddyRepository {
),
photoPath: row.data['photo_path'] as String?,
notes: (row.data['notes'] as String?) ?? '',
isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(
row.data['created_at'] as int,
),
Expand Down Expand Up @@ -813,6 +842,75 @@ class BuddyRepository {
}
}

/// Toggle favorite status for a buddy
Future<void> toggleFavorite(String buddyId) async {
try {
_log.info('Toggling favorite for buddy: $buddyId');
final now = DateTime.now().millisecondsSinceEpoch;
final buddy = await (_db.select(
_db.buddies,
)..where((t) => t.id.equals(buddyId))).getSingleOrNull();
if (buddy == null) return;
await (_db.update(_db.buddies)..where((t) => t.id.equals(buddyId))).write(
BuddiesCompanion(
isFavorite: Value(!buddy.isFavorite),
updatedAt: Value(now),
),
);
await _syncRepository.markRecordPending(
entityType: 'buddies',
recordId: buddyId,
localUpdatedAt: now,
);
SyncEventBus.notifyLocalChange();
_log.info('Toggled favorite for buddy: $buddyId');
} catch (e, stackTrace) {
_log.error(
'Failed to toggle favorite for buddy: $buddyId',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}

/// Set favorite status for a buddy
Future<void> setFavorite(String buddyId, bool isFavorite) async {
try {
_log.info('Setting favorite=$isFavorite for buddy: $buddyId');
final now = DateTime.now().millisecondsSinceEpoch;
final updated =
await (_db.update(
_db.buddies,
)..where((t) => t.id.equals(buddyId))).write(
BuddiesCompanion(
isFavorite: Value(isFavorite),
updatedAt: Value(now),
),
);
// A stale or deleted buddyId updates nothing; marking it pending would
// leave a sync record pointing at a row that does not exist.
if (updated == 0) {
_log.info('No buddy matched id, skipping favorite update: $buddyId');
return;
}
await _syncRepository.markRecordPending(
entityType: 'buddies',
recordId: buddyId,
localUpdatedAt: now,
);
SyncEventBus.notifyLocalChange();
_log.info('Set favorite=$isFavorite for buddy: $buddyId');
} catch (e, stackTrace) {
_log.error(
'Failed to set favorite for buddy: $buddyId',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}

/// Get dive count for a buddy
Future<int> getDiveCountForBuddy(String buddyId) async {
final result = await _db
Expand Down Expand Up @@ -980,6 +1078,7 @@ class BuddyRepository {
certificationAgency: null,
photoPath: row.photoPath,
notes: row.notes,
isFavorite: row.isFavorite,
createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt),
updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt),
);
Expand Down
5 changes: 5 additions & 0 deletions lib/features/buddies/domain/entities/buddy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Buddy extends Equatable {
final CertificationAgency? certificationAgency;
final String? photoPath;
final String notes;
final bool isFavorite;
final DateTime createdAt;
final DateTime updatedAt;

Expand All @@ -27,6 +28,7 @@ class Buddy extends Equatable {
this.certificationAgency,
this.photoPath,
this.notes = '',
this.isFavorite = false,
required this.createdAt,
required this.updatedAt,
});
Expand Down Expand Up @@ -65,6 +67,7 @@ class Buddy extends Equatable {
CertificationAgency? certificationAgency,
String? photoPath,
String? notes,
bool? isFavorite,
DateTime? createdAt,
DateTime? updatedAt,
}) {
Expand All @@ -78,6 +81,7 @@ class Buddy extends Equatable {
certificationAgency: certificationAgency ?? this.certificationAgency,
photoPath: photoPath ?? this.photoPath,
notes: notes ?? this.notes,
isFavorite: isFavorite ?? this.isFavorite,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Expand All @@ -94,6 +98,7 @@ class Buddy extends Equatable {
certificationAgency,
photoPath,
notes,
isFavorite,
createdAt,
updatedAt,
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,10 @@ class _BuddyEditPageState extends ConsumerState<BuddyEditPage> {
? _mergeCtrl?.mergedPhotoPath
: _originalBuddy?.photoPath,
notes: _notesController.text.trim(),
// Preserve favorite status (issue #638): this form has no favorite
// control, so a full-constructor rebuild would otherwise silently
// reset it to false on every save.
isFavorite: _originalBuddy?.isFavorite ?? false,
createdAt: _originalBuddy?.createdAt ?? now,
updatedAt: now,
);
Expand Down
Loading