add contact labels

This commit is contained in:
otsmr 2026-07-29 15:43:13 +02:00
parent 0291a4c3a2
commit 0e06a21948
26 changed files with 18350 additions and 131 deletions

View file

@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.4.3
- New: Contact labels
## 0.4.0 ## 0.4.0
- New: Encrypted Cloud Backup of Memories - New: Encrypted Cloud Backup of Memories

View file

@ -0,0 +1,91 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/labels.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
part 'labels.dao.g.dart';
@DriftAccessor(
tables: [
Labels,
ContactLabels,
],
)
class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
LabelsDao(super.db);
Stream<List<Label>> watchAllLabels() {
return (select(labels)..orderBy([(t) => OrderingTerm(expression: t.name)])).watch();
}
Future<List<Label>> getAllLabels() {
return (select(labels)..orderBy([(t) => OrderingTerm(expression: t.name)])).get();
}
Stream<List<Label>> watchContactLabels(int contactId) {
final query = select(contactLabels).join([
innerJoin(labels, labels.id.equalsExp(contactLabels.labelId)),
])..where(contactLabels.contactId.equals(contactId));
return query.watch().map(
(rows) => rows.map((row) => row.readTable(labels)).toList(),
);
}
Future<List<Label>> getContactLabels(int contactId) {
final query = select(contactLabels).join([
innerJoin(labels, labels.id.equalsExp(contactLabels.labelId)),
])..where(contactLabels.contactId.equals(contactId));
return query.get().then(
(rows) => rows.map((row) => row.readTable(labels)).toList(),
);
}
Future<void> setContactLabels(int contactId, List<int> labelIds) async {
final sanitizedLabelIds = labelIds.take(3).toList();
await transaction(() async {
await (delete(contactLabels)..where((t) => t.contactId.equals(contactId))).go();
if (sanitizedLabelIds.isNotEmpty) {
await batch((b) {
b.insertAll(
contactLabels,
sanitizedLabelIds.map(
(lId) => ContactLabelsCompanion.insert(
contactId: contactId,
labelId: lId,
),
),
);
});
}
});
}
Future<int> createLabel(String name, int textColor, int backgroundColor) {
final sanitizedName = name.length > 8 ? name.substring(0, 8) : name;
return into(labels).insert(
LabelsCompanion.insert(
name: sanitizedName,
textColor: textColor,
backgroundColor: backgroundColor,
),
);
}
Future<bool> updateLabel(int id, String name, int textColor, int backgroundColor) {
final sanitizedName = name.length > 8 ? name.substring(0, 8) : name;
return (update(labels)..where((t) => t.id.equals(id)))
.write(
LabelsCompanion(
name: Value(sanitizedName),
textColor: Value(textColor),
backgroundColor: Value(backgroundColor),
),
)
.then((rows) => rows > 0);
}
Future<int> deleteLabel(int id) {
return (delete(labels)..where((t) => t.id.equals(id))).go();
}
}

View file

@ -0,0 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'labels.dao.dart';
// ignore_for_file: type=lint
mixin _$LabelsDaoMixin on DatabaseAccessor<TwonlyDB> {
$LabelsTable get labels => attachedDatabase.labels;
$ContactsTable get contacts => attachedDatabase.contacts;
$ContactLabelsTable get contactLabels => attachedDatabase.contactLabels;
LabelsDaoManager get managers => LabelsDaoManager(this);
}
class LabelsDaoManager {
final _$LabelsDaoMixin _db;
LabelsDaoManager(this._db);
$$LabelsTableTableManager get labels =>
$$LabelsTableTableManager(_db.attachedDatabase, _db.labels);
$$ContactsTableTableManager get contacts =>
$$ContactsTableTableManager(_db.attachedDatabase, _db.contacts);
$$ContactLabelsTableTableManager get contactLabels =>
$$ContactLabelsTableTableManager(_db.attachedDatabase, _db.contactLabels);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
@DataClassName('Label')
class Labels extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
IntColumn get textColor => integer()();
IntColumn get backgroundColor => integer()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('ContactLabel')
class ContactLabels extends Table {
IntColumn get contactId => integer().references(
Contacts,
#userId,
onDelete: KeyAction.cascade,
)();
IntColumn get labelId => integer().references(
Labels,
#id,
onDelete: KeyAction.cascade,
)();
@override
Set<Column> get primaryKey => {contactId, labelId};
}

View file

@ -6,6 +6,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/daos/groups.dao.dart'; import 'package:twonly/src/database/daos/groups.dao.dart';
import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart';
import 'package:twonly/src/database/daos/labels.dao.dart';
import 'package:twonly/src/database/daos/mediafiles.dao.dart'; import 'package:twonly/src/database/daos/mediafiles.dao.dart';
import 'package:twonly/src/database/daos/messages.dao.dart'; import 'package:twonly/src/database/daos/messages.dao.dart';
import 'package:twonly/src/database/daos/reactions.dao.dart'; import 'package:twonly/src/database/daos/reactions.dao.dart';
@ -15,6 +16,7 @@ import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/drift_logging_interceptor.dart'; import 'package:twonly/src/database/drift_logging_interceptor.dart';
import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/labels.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/reactions.table.dart'; import 'package:twonly/src/database/tables/reactions.table.dart';
@ -59,6 +61,8 @@ part 'twonly.db.g.dart';
UserDiscoveryShares, UserDiscoveryShares,
Shortcuts, Shortcuts,
ShortcutMembers, ShortcutMembers,
Labels,
ContactLabels,
], ],
daos: [ daos: [
MessagesDao, MessagesDao,
@ -70,6 +74,7 @@ part 'twonly.db.g.dart';
UserDiscoveryDao, UserDiscoveryDao,
KeyVerificationDao, KeyVerificationDao,
ShortcutsDao, ShortcutsDao,
LabelsDao,
], ],
) )
class TwonlyDB extends _$TwonlyDB { class TwonlyDB extends _$TwonlyDB {
@ -82,7 +87,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection); TwonlyDB.forTesting(DatabaseConnection super.connection);
@override @override
int get schemaVersion => 23; int get schemaVersion => 24;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
final connection = driftDatabase( final connection = driftDatabase(
@ -285,6 +290,10 @@ class TwonlyDB extends _$TwonlyDB {
schema.mediaFiles.blurhash, schema.mediaFiles.blurhash,
); );
}, },
from23To24: (m, schema) async {
await m.createTable(schema.labels);
await m.createTable(schema.contactLabels);
},
)(m, from, to); )(m, from, to);
}, },
); );

File diff suppressed because it is too large Load diff

View file

@ -12133,6 +12133,546 @@ i1.GeneratedColumn<String> _column_256(String aliasedName) =>
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
$customConstraints: 'NULL', $customConstraints: 'NULL',
); );
final class Schema24 extends i0.VersionedSchema {
Schema24({required super.database}) : super(version: 24);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalSignedPreKeyStores,
messageActions,
groupHistories,
keyVerifications,
verificationTokens,
userDiscoveryAnnouncedUsers,
userDiscoveryUserRelations,
userDiscoveryOtherPromotions,
userDiscoveryOwnPromotions,
userDiscoveryShares,
shortcuts,
shortcutMembers,
labels,
contactLabels,
];
late final Shape57 contacts = Shape57(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(user_id)'],
columns: [
_column_106,
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_211,
_column_212,
_column_213,
_column_249,
_column_250,
_column_251,
_column_252,
_column_253,
_column_254,
_column_247,
_column_214,
_column_215,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 groups = Shape23(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id)'],
columns: [
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
_column_130,
_column_131,
_column_132,
_column_133,
_column_134,
_column_118,
_column_135,
_column_136,
_column_137,
_column_138,
_column_139,
_column_140,
_column_141,
_column_142,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape58 mediaFiles = Shape58(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(media_id)'],
columns: [
_column_143,
_column_144,
_column_145,
_column_255,
_column_256,
_column_146,
_column_147,
_column_148,
_column_149,
_column_239,
_column_240,
_column_207,
_column_150,
_column_151,
_column_152,
_column_153,
_column_154,
_column_155,
_column_156,
_column_157,
_column_244,
_column_245,
_column_118,
_column_241,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 messages = Shape25(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id)'],
columns: [
_column_158,
_column_159,
_column_160,
_column_144,
_column_161,
_column_162,
_column_163,
_column_164,
_column_165,
_column_153,
_column_166,
_column_167,
_column_168,
_column_169,
_column_118,
_column_170,
_column_171,
_column_172,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 messageHistories = Shape26(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_174,
_column_175,
_column_161,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 reactions = Shape27(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
columns: [_column_174, _column_176, _column_177, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 groupMembers = Shape38(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
columns: [
_column_158,
_column_178,
_column_179,
_column_180,
_column_209,
_column_210,
_column_181,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape37 receipts = Shape37(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
_column_208,
_column_187,
_column_188,
_column_189,
_column_190,
_column_191,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 receivedReceipts = Shape30(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [_column_182, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 signalIdentityKeyStores = Shape31(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_194, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 signalPreKeyStores = Shape32(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
columns: [_column_195, _column_196, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 signalSenderKeyStores = Shape11(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
columns: [_column_197, _column_198],
attachedDatabase: database,
),
alias: null,
);
late final Shape33 signalSessionStores = Shape33(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_199, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 signalSignedPreKeyStores = Shape50(
source: i0.VersionedTable(
entityName: 'signal_signed_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
columns: [_column_242, _column_243, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 messageActions = Shape34(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
columns: [_column_174, _column_183, _column_144, _column_200],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 groupHistories = Shape35(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_history_id)'],
columns: [
_column_201,
_column_158,
_column_202,
_column_203,
_column_204,
_column_205,
_column_206,
_column_144,
_column_200,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape54 keyVerifications = Shape54(
source: i0.VersionedTable(
entityName: 'key_verifications',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_216,
_column_183,
_column_144,
_column_248,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 verificationTokens = Shape41(
source: i0.VersionedTable(
entityName: 'verification_tokens',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_217, _column_218, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 userDiscoveryAnnouncedUsers = Shape52(
source: i0.VersionedTable(
entityName: 'user_discovery_announced_users',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
columns: [
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_224,
_column_246,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 userDiscoveryUserRelations = Shape43(
source: i0.VersionedTable(
entityName: 'user_discovery_user_relations',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
columns: [_column_225, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 userDiscoveryOtherPromotions = Shape44(
source: i0.VersionedTable(
entityName: 'user_discovery_other_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
columns: [
_column_226,
_column_228,
_column_229,
_column_230,
_column_231,
_column_227,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 userDiscoveryOwnPromotions = Shape45(
source: i0.VersionedTable(
entityName: 'user_discovery_own_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_232, _column_183, _column_233],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 userDiscoveryShares = Shape46(
source: i0.VersionedTable(
entityName: 'user_discovery_shares',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_234, _column_235, _column_175],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 shortcuts = Shape47(
source: i0.VersionedTable(
entityName: 'shortcuts',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_173, _column_236, _column_237],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 shortcutMembers = Shape48(
source: i0.VersionedTable(
entityName: 'shortcut_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
columns: [_column_238, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape59 labels = Shape59(
source: i0.VersionedTable(
entityName: 'labels',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_193,
_column_257,
_column_258,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape60 contactLabels = Shape60(
source: i0.VersionedTable(
entityName: 'contact_labels',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(contact_id, label_id)'],
columns: [_column_183, _column_259],
attachedDatabase: database,
),
alias: null,
);
}
class Shape59 extends i0.VersionedTable {
Shape59({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get textColor =>
columnsByName['text_color']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get backgroundColor =>
columnsByName['background_color']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_257(String aliasedName) =>
i1.GeneratedColumn<int>(
'text_color',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_258(String aliasedName) =>
i1.GeneratedColumn<int>(
'background_color',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
class Shape60 extends i0.VersionedTable {
Shape60({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get contactId =>
columnsByName['contact_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get labelId =>
columnsByName['label_id']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_259(String aliasedName) =>
i1.GeneratedColumn<int>(
'label_id',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL REFERENCES labels(id)ON DELETE CASCADE',
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -12156,6 +12696,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21, required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22, required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23, required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -12269,6 +12810,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from22To23(migrator, schema); await from22To23(migrator, schema);
return 23; return 23;
case 23:
final schema = Schema24(database: database);
final migrator = i1.Migrator(database, schema);
await from23To24(migrator, schema);
return 24;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -12298,6 +12844,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21, required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22, required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23, required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@ -12322,5 +12869,6 @@ i1.OnUpgrade stepByStep({
from20To21: from20To21, from20To21: from20To21,
from21To22: from21To22, from21To22: from21To22,
from22To23: from22To23, from22To23: from22To23,
from23To24: from23To24,
), ),
); );

View file

@ -3917,7 +3917,7 @@ abstract class AppLocalizations {
/// No description provided for @passwordlessRecoveryTrustedFriends. /// No description provided for @passwordlessRecoveryTrustedFriends.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Trusted Friends'** /// **'Account recovery'**
String get passwordlessRecoveryTrustedFriends; String get passwordlessRecoveryTrustedFriends;
/// No description provided for @passwordlessRecoveryDoneBtn. /// No description provided for @passwordlessRecoveryDoneBtn.
@ -4285,6 +4285,90 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show flame restore warning'** /// **'Show flame restore warning'**
String get settingsShowRestoreFlameTitle; String get settingsShowRestoreFlameTitle;
/// No description provided for @contactLabelsTitle.
///
/// In en, this message translates to:
/// **'Contact Labels'**
String get contactLabelsTitle;
/// No description provided for @contactLabelsSubtitleEmpty.
///
/// In en, this message translates to:
/// **'No labels selected'**
String get contactLabelsSubtitleEmpty;
/// No description provided for @contactLabelsMaxLimit.
///
/// In en, this message translates to:
/// **'Maximum 3 labels per contact'**
String get contactLabelsMaxLimit;
/// No description provided for @createLabel.
///
/// In en, this message translates to:
/// **'Create new label'**
String get createLabel;
/// No description provided for @editLabel.
///
/// In en, this message translates to:
/// **'Edit label'**
String get editLabel;
/// No description provided for @deleteLabel.
///
/// In en, this message translates to:
/// **'Delete label'**
String get deleteLabel;
/// No description provided for @deleteLabelConfirmation.
///
/// In en, this message translates to:
/// **'Are you sure you want to delete this label? It will be removed from all contacts.'**
String get deleteLabelConfirmation;
/// No description provided for @labelNameHint.
///
/// In en, this message translates to:
/// **'Label name'**
String get labelNameHint;
/// No description provided for @labelTextColor.
///
/// In en, this message translates to:
/// **'Text color'**
String get labelTextColor;
/// No description provided for @labelBackgroundColor.
///
/// In en, this message translates to:
/// **'Background color'**
String get labelBackgroundColor;
/// No description provided for @customColor.
///
/// In en, this message translates to:
/// **'Custom Color'**
String get customColor;
/// No description provided for @hue.
///
/// In en, this message translates to:
/// **'Hue'**
String get hue;
/// No description provided for @saturation.
///
/// In en, this message translates to:
/// **'Saturation'**
String get saturation;
/// No description provided for @brightness.
///
/// In en, this message translates to:
/// **'Brightness'**
String get brightness;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2261,7 +2261,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get passwordlessRecoverySelectFriends => 'Freunde auswählen'; String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
@override @override
String get passwordlessRecoveryTrustedFriends => 'Vertrauenswürdige Freunde'; String get passwordlessRecoveryTrustedFriends => 'Kontowiederherstellung';
@override @override
String passwordlessRecoveryDoneBtn(num count) { String passwordlessRecoveryDoneBtn(num count) {
@ -2484,4 +2484,47 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsShowRestoreFlameTitle => String get settingsShowRestoreFlameTitle =>
'Hinweis zur Flammen-Wiederherstellung anzeigen'; 'Hinweis zur Flammen-Wiederherstellung anzeigen';
@override
String get contactLabelsTitle => 'Kontaktlabels';
@override
String get contactLabelsSubtitleEmpty => 'Keine Labels ausgewählt';
@override
String get contactLabelsMaxLimit => 'Maximal 3 Labels pro Kontakt';
@override
String get createLabel => 'Neues Label erstellen';
@override
String get editLabel => 'Label bearbeiten';
@override
String get deleteLabel => 'Label löschen';
@override
String get deleteLabelConfirmation =>
'Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.';
@override
String get labelNameHint => 'Label-Name';
@override
String get labelTextColor => 'Textfarbe';
@override
String get labelBackgroundColor => 'Hintergrundfarbe';
@override
String get customColor => 'Eigene Farbe';
@override
String get hue => 'Farbton';
@override
String get saturation => 'Sättigung';
@override
String get brightness => 'Helligkeit';
} }

View file

@ -2245,7 +2245,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get passwordlessRecoverySelectFriends => 'Select trusted friends'; String get passwordlessRecoverySelectFriends => 'Select trusted friends';
@override @override
String get passwordlessRecoveryTrustedFriends => 'Trusted Friends'; String get passwordlessRecoveryTrustedFriends => 'Account recovery';
@override @override
String passwordlessRecoveryDoneBtn(num count) { String passwordlessRecoveryDoneBtn(num count) {
@ -2461,4 +2461,47 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsShowRestoreFlameTitle => 'Show flame restore warning'; String get settingsShowRestoreFlameTitle => 'Show flame restore warning';
@override
String get contactLabelsTitle => 'Contact Labels';
@override
String get contactLabelsSubtitleEmpty => 'No labels selected';
@override
String get contactLabelsMaxLimit => 'Maximum 3 labels per contact';
@override
String get createLabel => 'Create new label';
@override
String get editLabel => 'Edit label';
@override
String get deleteLabel => 'Delete label';
@override
String get deleteLabelConfirmation =>
'Are you sure you want to delete this label? It will be removed from all contacts.';
@override
String get labelNameHint => 'Label name';
@override
String get labelTextColor => 'Text color';
@override
String get labelBackgroundColor => 'Background color';
@override
String get customColor => 'Custom Color';
@override
String get hue => 'Hue';
@override
String get saturation => 'Saturation';
@override
String get brightness => 'Brightness';
} }

@ -1 +1 @@
Subproject commit c5f554b36deab290c091ae46b3af1255f74c6b98 Subproject commit d08a6d4261135b80fba6a02f1e7e70fefcf17b8d

View file

@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
class ContactLabels extends StatefulWidget {
const ContactLabels({
required this.contactId,
this.fontSize = 8,
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
this.emptyText,
this.showEmptyText = false,
super.key,
});
final int contactId;
final double fontSize;
final EdgeInsetsGeometry padding;
final String? emptyText;
final bool showEmptyText;
@override
State<ContactLabels> createState() => _ContactLabelsState();
}
class _ContactLabelsState extends State<ContactLabels> {
List<Label> _labels = [];
late StreamSubscription<List<Label>> _sub;
@override
void initState() {
super.initState();
_sub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen((
labels,
) {
if (mounted) {
setState(() {
_labels = labels;
});
}
});
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_labels.isEmpty) {
if (!widget.showEmptyText && widget.emptyText == null) {
return const SizedBox.shrink();
}
return Text(
widget.emptyText ?? context.lang.contactLabelsSubtitleEmpty,
style: TextStyle(
fontSize: widget.fontSize,
color: Theme.of(context).disabledColor,
),
);
}
return Wrap(
spacing: 4,
runSpacing: 2,
children: _labels.map((label) {
final bgColor = Color(label.backgroundColor);
final textColor = Color(label.textColor);
return Container(
padding: widget.padding,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label.name,
style: TextStyle(
fontSize: widget.fontSize,
color: textColor,
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
);
}
}
Widget? buildContactLabelsSubtitle({
required int contactId,
required List<Label> labels,
Widget? additionalSubtitle,
}) {
if (additionalSubtitle != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
additionalSubtitle,
if (labels.isNotEmpty)
ContactLabels(contactId: contactId),
],
);
}
if (labels.isEmpty) {
return null;
}
return ContactLabels(contactId: contactId);
}
class ContactLabelsSubtitleBuilder extends StatelessWidget {
const ContactLabelsSubtitleBuilder({
required this.contactId,
required this.builder,
this.additionalSubtitle,
super.key,
});
final int contactId;
final Widget? additionalSubtitle;
final Widget Function(BuildContext context, Widget? subtitleWidget) builder;
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Label>>(
stream: twonlyDB.labelsDao.watchContactLabels(contactId),
builder: (context, snapshot) {
final labels = snapshot.data ?? [];
final subtitle = buildContactLabelsSubtitle(
contactId: contactId,
labels: labels,
additionalSubtitle: additionalSubtitle,
);
return builder(context, subtitle);
},
);
}
}

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class CustomColorPickerDialog extends StatefulWidget {
const CustomColorPickerDialog({
required this.initialColor,
super.key,
});
final Color initialColor;
@override
State<CustomColorPickerDialog> createState() => _CustomColorPickerDialogState();
}
class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
late HSVColor _hsvColor;
@override
void initState() {
super.initState();
_hsvColor = HSVColor.fromColor(widget.initialColor);
}
@override
Widget build(BuildContext context) {
final currentColor = _hsvColor.toColor();
return AlertDialog(
title: Text(context.lang.customColor),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color preview box
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: currentColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
),
),
const SizedBox(height: 20),
// Hue Slider
Text(context.lang.hue, style: const TextStyle(fontWeight: FontWeight.w600)),
Slider(
value: _hsvColor.hue,
max: 360,
activeColor: HSVColor.fromAHSV(1, _hsvColor.hue, 1, 1).toColor(),
onChanged: (val) {
setState(() {
_hsvColor = _hsvColor.withHue(val);
});
},
),
// Saturation Slider
Text(context.lang.saturation, style: const TextStyle(fontWeight: FontWeight.w600)),
Slider(
value: _hsvColor.saturation,
onChanged: (val) {
setState(() {
_hsvColor = _hsvColor.withSaturation(val);
});
},
),
// Brightness / Value Slider
Text(context.lang.brightness, style: const TextStyle(fontWeight: FontWeight.w600)),
Slider(
value: _hsvColor.value,
onChanged: (val) {
setState(() {
_hsvColor = _hsvColor.withValue(val);
});
},
),
],
),
),
actions: [
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.text,
onPressed: () => Navigator.of(context).pop(),
child: Text(context.lang.cancel),
),
),
const SizedBox(width: 8),
Expanded(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () => Navigator.of(context).pop(currentColor.toARGB32()),
child: Text(context.lang.ok),
),
),
],
),
],
);
}
}

View file

@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/custom_color_picker_dialog.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class LabelEditorBottomSheet extends StatefulWidget {
const LabelEditorBottomSheet({
this.label,
super.key,
});
final Label? label;
@override
State<LabelEditorBottomSheet> createState() => _LabelEditorBottomSheetState();
}
class _LabelEditorBottomSheetState extends State<LabelEditorBottomSheet> {
late TextEditingController _nameController;
late int _selectedBgColor;
late int _selectedTextColor;
static const List<int> defaultBgColors = [
0xFFE57373, // Red
0xFFF06292, // Pink
0xFFBA68C8, // Purple
0xFF7986CB, // Indigo
0xFF64B5F6, // Blue
0xFF4DD0E1, // Cyan
0xFF4DB6AC, // Teal
0xFF81C784, // Green
0xFFFFB74D, // Amber
0xFFFF8A65, // Deep Orange
0xFF90A4AE, // Blue Grey
0xFF424242, // Dark Grey
];
static const List<int> defaultTextColors = [
0xFFFFFFFF, // White
0xFF121212, // Dark/Black
0xFF1B263B, // Deep Navy
0xFF8B0000, // Dark Red
0xFF004D40, // Dark Teal
0xFF4A148C, // Dark Purple
];
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.label?.name ?? '');
_selectedBgColor = widget.label?.backgroundColor ?? defaultBgColors[4];
_selectedTextColor = widget.label?.textColor ?? defaultTextColors[0];
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _pickCustomColor({required bool isBgColor}) async {
final initial = isBgColor ? Color(_selectedBgColor) : Color(_selectedTextColor);
final pickedColorInt = await showDialog<int>(
context: context,
builder: (context) => CustomColorPickerDialog(initialColor: initial),
);
if (pickedColorInt != null && mounted) {
setState(() {
if (isBgColor) {
_selectedBgColor = pickedColorInt;
} else {
_selectedTextColor = pickedColorInt;
}
});
}
}
@override
Widget build(BuildContext context) {
final isEditing = widget.label != null;
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag indicator handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
isEditing ? context.lang.editLabel : context.lang.createLabel,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// Interactive Inline Label Badge
Center(
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100, maxWidth: 200),
child: Container(
decoration: BoxDecoration(
color: Color(_selectedBgColor),
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
child: TextField(
controller: _nameController,
autofocus: true,
maxLength: 8,
textAlign: TextAlign.center,
textCapitalization: TextCapitalization.words,
style: TextStyle(
color: Color(_selectedTextColor),
fontSize: 13,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
hintText: context.lang.labelNameHint,
hintStyle: TextStyle(
color: Color(_selectedTextColor).withValues(alpha: 0.6),
fontSize: 13,
fontWeight: FontWeight.w600,
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
counterText: '',
),
onChanged: (_) => setState(() {}),
),
),
),
),
),
const SizedBox(height: 24),
// Background Color Section
Text(
context.lang.labelBackgroundColor,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: [
...defaultBgColors.map((colorValue) {
final selected = _selectedBgColor == colorValue;
return GestureDetector(
onTap: () {
setState(() {
_selectedBgColor = colorValue;
});
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Color(colorValue),
shape: BoxShape.circle,
),
child: selected
? Icon(
Icons.check,
size: 18,
color: Color(colorValue).computeLuminance() > 0.5
? Colors.black
: Colors.white,
)
: null,
),
);
}),
// Custom Color Picker Button
GestureDetector(
onTap: () => _pickCustomColor(isBgColor: true),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [
Colors.red,
Colors.yellow,
Colors.green,
Colors.cyan,
Colors.blue,
Colors.purple,
Colors.red,
],
),
),
child: const Icon(
Icons.colorize,
size: 18,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 20),
// Text Color Section
Text(
context.lang.labelTextColor,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: [
...defaultTextColors.map((colorValue) {
final selected = _selectedTextColor == colorValue;
return GestureDetector(
onTap: () {
setState(() {
_selectedTextColor = colorValue;
});
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Color(colorValue),
shape: BoxShape.circle,
),
child: selected
? Icon(
Icons.check,
size: 18,
color: Color(colorValue).computeLuminance() > 0.5
? Colors.black
: Colors.white,
)
: null,
),
);
}),
// Custom Color Picker Button for Text
GestureDetector(
onTap: () => _pickCustomColor(isBgColor: false),
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [
Colors.red,
Colors.yellow,
Colors.green,
Colors.cyan,
Colors.blue,
Colors.purple,
Colors.red,
],
),
),
child: const Icon(
Icons.colorize,
size: 18,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.text,
onPressed: () => Navigator.of(context).pop(),
child: Text(context.lang.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () {
final name = _nameController.text.trim();
if (name.isNotEmpty) {
Navigator.of(context).pop({
'name': name,
'backgroundColor': _selectedBgColor,
'textColor': _selectedTextColor,
});
}
},
child: Text(context.lang.ok),
),
),
],
),
],
),
),
),
);
}
}

View file

@ -6,13 +6,13 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/group.context_menu.dart'; import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
@ -42,6 +42,8 @@ class _UserListItem extends State<GroupListItemComp> {
StreamSubscription<Message?>? _lastMessageStream; StreamSubscription<Message?>? _lastMessageStream;
StreamSubscription<Reaction?>? _lastReactionStream; StreamSubscription<Reaction?>? _lastReactionStream;
StreamSubscription<List<MediaFile>>? _lastMediaFilesStream; StreamSubscription<List<MediaFile>>? _lastMediaFilesStream;
Contact? _directContact;
StreamSubscription<List<Contact>>? _directContactStream;
List<Message> _previewMessages = []; List<Message> _previewMessages = [];
final List<MediaFile> _previewMediaFiles = []; final List<MediaFile> _previewMediaFiles = [];
@ -60,6 +62,7 @@ class _UserListItem extends State<GroupListItemComp> {
_lastReactionStream?.cancel(); _lastReactionStream?.cancel();
_lastMessageStream?.cancel(); _lastMessageStream?.cancel();
_lastMediaFilesStream?.cancel(); _lastMediaFilesStream?.cancel();
_directContactStream?.cancel();
super.dispose(); super.dispose();
} }
@ -102,6 +105,19 @@ class _UserListItem extends State<GroupListItemComp> {
setState(() {}); setState(() {});
}); });
if (widget.group.isDirectChat) {
_directContactStream = twonlyDB.groupsDao
.watchGroupContact(widget.group.groupId)
.listen((contacts) {
if (!mounted) return;
if (contacts.isNotEmpty) {
setState(() {
_directContact = contacts.first;
_receiverDeletedAccount = _directContact!.accountDeleted;
});
}
});
} else {
final groupContacts = await twonlyDB.groupsDao.getGroupContact( final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId, widget.group.groupId,
); );
@ -110,6 +126,7 @@ class _UserListItem extends State<GroupListItemComp> {
_receiverDeletedAccount = groupContacts.first.accountDeleted; _receiverDeletedAccount = groupContacts.first.accountDeleted;
} }
} }
}
void _updateState( void _updateState(
Message? newLastMessage, Message? newLastMessage,
@ -239,8 +256,12 @@ class _UserListItem extends State<GroupListItemComp> {
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [
Text( Flexible(
substringBy(widget.group.groupName, 30), child: Text(
widget.group.groupName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
), ),
const SizedBox(width: 3), const SizedBox(width: 3),
VerificationBadgeComp( VerificationBadgeComp(
@ -249,6 +270,18 @@ class _UserListItem extends State<GroupListItemComp> {
clickable: false, clickable: false,
size: 12, size: 12,
), ),
if (widget.group.isDirectChat && _directContact != null) ...[
const SizedBox(width: 6),
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: ContactLabels(
contactId: _directContact!.userId,
),
),
),
],
], ],
), ),
subtitle: _receiverDeletedAccount subtitle: _receiverDeletedAccount

View file

@ -16,6 +16,7 @@ import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/themes/colors.dart'; import 'package:twonly/src/visual/themes/colors.dart';
@ -303,7 +304,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
Expanded( Expanded(
child: ColoredBox( child: ColoredBox(
color: Colors.transparent, color: Colors.transparent,
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [ children: [
Text( Text(
substringBy(group.groupName, 20), substringBy(group.groupName, 20),
@ -317,6 +322,17 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
FlameCounterWidget(groupId: group.groupId), FlameCounterWidget(groupId: group.groupId),
], ],
), ),
if (group.isDirectChat)
StreamBuilder<List<Contact>>(
stream: twonlyDB.groupsDao.watchGroupContact(group.groupId),
builder: (context, snapshot) {
final contacts = snapshot.data ?? [];
if (contacts.isEmpty) return const SizedBox.shrink();
return ContactLabels(contactId: contacts.first.userId);
},
),
],
),
), ),
), ),
], ],

View file

@ -12,6 +12,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/group.context_menu.dart'; import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
@ -216,32 +217,39 @@ class _StartNewChatView extends State<StartNewChatView> {
} }
if (i < filteredContacts.length) { if (i < filteredContacts.length) {
final contact = filteredContacts[i];
return UserContextMenu( return UserContextMenu(
key: ValueKey(filteredContacts[i].userId), key: ValueKey(contact.userId),
contact: filteredContacts[i], contact: contact,
child: ListTile( child: ContactLabelsSubtitleBuilder(
contactId: contact.userId,
builder: (context, subtitleWidget) {
return ListTile(
title: Row( title: Row(
children: [ children: [
Text(getContactDisplayName(filteredContacts[i])), Text(getContactDisplayName(contact)),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
right: 8, right: 8,
left: 1, left: 1,
), ),
child: VerificationBadgeComp( child: VerificationBadgeComp(
contact: filteredContacts[i], contact: contact,
), ),
), ),
FlameCounterWidget( FlameCounterWidget(
contactId: filteredContacts[i].userId, contactId: contact.userId,
), ),
], ],
), ),
subtitle: subtitleWidget,
leading: AvatarIcon( leading: AvatarIcon(
contactId: filteredContacts[i].userId, contactId: contact.userId,
fontSize: 13, fontSize: 13,
), ),
onTap: () => _onTapUser(filteredContacts[i]), onTap: () => _onTapUser(contact),
);
},
), ),
); );
} }

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart'; import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
@ -20,6 +21,7 @@ import 'package:twonly/src/visual/views/contact/contact_components/mutual_groups
import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart'; import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart';
import 'package:twonly/src/visual/views/contact/contact_components/user_discovery_contact_settings.comp.dart'; import 'package:twonly/src/visual/views/contact/contact_components/user_discovery_contact_settings.comp.dart';
import 'package:twonly/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart'; import 'package:twonly/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart';
import 'package:twonly/src/visual/views/contact/select_contact_labels.view.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart';
class ContactView extends StatefulWidget { class ContactView extends StatefulWidget {
@ -225,6 +227,21 @@ class _ContactViewState extends State<ContactView> {
userService.currentUser.userId, userService.currentUser.userId,
), ),
), ),
ContactLabelsSubtitleBuilder(
contactId: contact.userId,
builder: (context, subtitleWidget) {
return BetterListTile(
icon: FontAwesomeIcons.tag,
text: context.lang.contactLabelsTitle,
subtitle: subtitleWidget,
onTap: () {
context.navPush(
SelectContactLabelsView(contactId: contact.userId),
);
},
);
},
),
const Divider(), const Divider(),
RestoreFlameComp( RestoreFlameComp(
contactId: widget.userId, contactId: widget.userId,

View file

@ -0,0 +1,313 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/label_editor_bottom_sheet.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class SelectContactLabelsView extends StatefulWidget {
const SelectContactLabelsView({
required this.contactId,
super.key,
});
final int contactId;
@override
State<SelectContactLabelsView> createState() => _SelectContactLabelsViewState();
}
class _SelectContactLabelsViewState extends State<SelectContactLabelsView> {
Contact? _contact;
List<Label> _allLabels = [];
Set<int> _selectedLabelIds = {};
late StreamSubscription<Contact?> _contactSub;
late StreamSubscription<List<Label>> _allLabelsSub;
late StreamSubscription<List<Label>> _contactLabelsSub;
@override
void initState() {
super.initState();
_contactSub = twonlyDB.contactsDao.watchContact(widget.contactId).listen((contact) {
if (mounted) {
setState(() {
_contact = contact;
});
}
});
_allLabelsSub = twonlyDB.labelsDao.watchAllLabels().listen((labels) {
if (mounted) {
setState(() {
_allLabels = labels;
});
}
});
_contactLabelsSub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen((labels) {
if (mounted) {
setState(() {
_selectedLabelIds = labels.map((l) => l.id).toSet();
});
}
});
}
@override
void dispose() {
_contactSub.cancel();
_allLabelsSub.cancel();
_contactLabelsSub.cancel();
super.dispose();
}
Future<void> _toggleLabel(int labelId) async {
final newSet = Set<int>.from(_selectedLabelIds);
if (newSet.contains(labelId)) {
newSet.remove(labelId);
} else {
if (newSet.length >= 3) {
showSnackbar(
context,
context.lang.contactLabelsMaxLimit,
level: SnackbarLevel.warning,
);
return;
}
newSet.add(labelId);
}
setState(() {
_selectedLabelIds = newSet;
});
await twonlyDB.labelsDao.setContactLabels(widget.contactId, _selectedLabelIds.toList());
}
Future<void> _createLabel() async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const LabelEditorBottomSheet(),
);
if (result != null && mounted) {
final name = result['name'] as String;
final bgColor = result['backgroundColor'] as int;
final textColor = result['textColor'] as int;
await twonlyDB.labelsDao.createLabel(name, textColor, bgColor);
}
}
Future<void> _editLabel(Label label) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => LabelEditorBottomSheet(label: label),
);
if (result != null && mounted) {
final name = result['name'] as String;
final bgColor = result['backgroundColor'] as int;
final textColor = result['textColor'] as int;
await twonlyDB.labelsDao.updateLabel(label.id, name, textColor, bgColor);
}
}
Future<void> _deleteLabel(Label label) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.lang.deleteLabel),
content: Text(context.lang.deleteLabelConfirmation),
actions: [
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.text,
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.lang.cancel),
),
),
const SizedBox(width: 8),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.lang.deleteLabel),
),
),
],
),
],
),
);
if ((confirm ?? false) && mounted) {
await twonlyDB.labelsDao.deleteLabel(label.id);
}
}
Widget _buildContactHeader() {
if (_contact == null) return const SizedBox.shrink();
final contact = _contact!;
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
AvatarIcon(contactId: contact.userId, fontSize: 24),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
getContactDisplayName(contact, maxLength: 25),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
if (getContactDisplayName(contact) != contact.username)
Text(
'@${contact.username}',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 6),
ContactLabels(contactId: contact.userId),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.contactLabelsTitle),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.lang.createLabel,
onPressed: _createLabel,
),
],
),
body: Column(
children: [
_buildContactHeader(),
Expanded(
child: _allLabels.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
context.lang.contactLabelsSubtitleEmpty,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 16),
MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: _createLabel,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 6),
Text(context.lang.createLabel),
],
),
),
],
),
)
: ListView.builder(
itemCount: _allLabels.length,
itemBuilder: (context, index) {
final label = _allLabels[index];
final isSelected = _selectedLabelIds.contains(label.id);
return CheckboxListTile(
value: isSelected,
onChanged: (_) => _toggleLabel(label.id),
title: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Color(label.backgroundColor),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label.name,
style: TextStyle(
color: Color(label.textColor),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: context.lang.editLabel,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _editLabel(label),
child: const Padding(
padding: EdgeInsets.all(6),
child: FaIcon(FontAwesomeIcons.penToSquare, size: 16),
),
),
),
const SizedBox(width: 2),
Tooltip(
message: context.lang.deleteLabel,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _deleteLabel(label),
child: const Padding(
padding: EdgeInsets.all(6),
child: FaIcon(FontAwesomeIcons.trashCan, size: 16, color: Colors.red),
),
),
),
],
),
);
},
),
),
],
),
);
}
}

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart'; import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
@ -248,7 +249,10 @@ class _GroupViewState extends State<GroupView> {
group: _group!, group: _group!,
contact: member.$1, contact: member.$1,
member: member.$2, member: member.$2,
child: BetterListTile( child: ContactLabelsSubtitleBuilder(
contactId: member.$1.userId,
builder: (context, subtitleWidget) {
return BetterListTile(
padding: const EdgeInsets.only(left: 13), padding: const EdgeInsets.only(left: 13),
leading: AvatarIcon( leading: AvatarIcon(
contactId: member.$1.userId, contactId: member.$1.userId,
@ -266,6 +270,7 @@ class _GroupViewState extends State<GroupView> {
), ),
], ],
), ),
subtitle: subtitleWidget,
trailing: (member.$2.memberState == MemberState.admin) trailing: (member.$2.memberState == MemberState.admin)
? Text(context.lang.admin) ? Text(context.lang.admin)
: null, : null,
@ -277,6 +282,8 @@ class _GroupViewState extends State<GroupView> {
), ),
); );
}, },
);
},
), ),
); );
}), }),

View file

@ -5,9 +5,12 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class GroupCreateSelectGroupNameView extends StatefulWidget { class GroupCreateSelectGroupNameView extends StatefulWidget {
const GroupCreateSelectGroupNameView({ const GroupCreateSelectGroupNameView({
@ -59,20 +62,28 @@ class _GroupCreateSelectGroupNameViewState
title: Text(context.lang.selectGroupName), title: Text(context.lang.selectGroupName),
), ),
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
floatingActionButton: FilledButton.icon( floatingActionButton: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: (textFieldGroupName.text.isEmpty || _isLoading) onPressed: (textFieldGroupName.text.isEmpty || _isLoading)
? null ? null
: _createNewGroup, : _createNewGroup,
label: Text(context.lang.createGroup), child: Row(
icon: _isLoading mainAxisSize: MainAxisSize.min,
? const SizedBox( children: [
if (_isLoading)
const SizedBox(
width: 15, width: 15,
height: 15, height: 15,
child: CircularProgressIndicator.adaptive( child: CircularProgressIndicator.adaptive(
strokeWidth: 1, strokeWidth: 1,
), ),
) )
: const FaIcon(FontAwesomeIcons.penToSquare), else
const FaIcon(FontAwesomeIcons.penToSquare, size: 16),
const SizedBox(width: 8),
Text(context.lang.createGroup),
],
),
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
@ -111,20 +122,35 @@ class _GroupCreateSelectGroupNameViewState
return UserContextMenu( return UserContextMenu(
key: ValueKey(user.userId), key: ValueKey(user.userId),
contact: user, contact: user,
child: ListTile( child: ContactLabelsSubtitleBuilder(
contactId: user.userId,
builder: (context, subtitleWidget) {
return ListTile(
title: Row( title: Row(
children: [ children: [
Text(getContactDisplayName(user)), Text(getContactDisplayName(user)),
Padding(
padding: const EdgeInsets.only(
right: 8,
left: 1,
),
child: VerificationBadgeComp(
contact: user,
),
),
FlameCounterWidget( FlameCounterWidget(
contactId: user.userId, contactId: user.userId,
prefix: true, prefix: true,
), ),
], ],
), ),
subtitle: subtitleWidget,
leading: AvatarIcon( leading: AvatarIcon(
contactId: user.userId, contactId: user.userId,
fontSize: 13, fontSize: 13,
), ),
);
},
), ),
); );
}, },

View file

@ -10,11 +10,14 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/contact_chip.element.dart'; import 'package:twonly/src/visual/elements/contact_chip.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart'; import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart';
class GroupCreateSelectMembersView extends StatefulWidget { class GroupCreateSelectMembersView extends StatefulWidget {
@ -131,14 +134,21 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
), ),
), ),
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
floatingActionButton: FilledButton.icon( floatingActionButton: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: selectedUsers.isEmpty ? null : submitChanges, onPressed: selectedUsers.isEmpty ? null : submitChanges,
label: Text( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.penToSquare, size: 16),
const SizedBox(width: 8),
Text(
widget.groupId == null widget.groupId == null
? context.lang.next ? context.lang.next
: context.lang.updateGroup, : context.lang.updateGroup,
), ),
icon: const FaIcon(FontAwesomeIcons.penToSquare), ],
),
), ),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
@ -209,19 +219,32 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
return UserContextMenu( return UserContextMenu(
key: ValueKey(user.userId), key: ValueKey(user.userId),
contact: user, contact: user,
child: ListTile( child: ContactLabelsSubtitleBuilder(
contactId: user.userId,
additionalSubtitle: alreadyInGroup.contains(user.userId)
? Text(context.lang.alreadyInGroup)
: null,
builder: (context, subtitleWidget) {
return ListTile(
title: Row( title: Row(
children: [ children: [
Text(getContactDisplayName(user)), Text(getContactDisplayName(user)),
Padding(
padding: const EdgeInsets.only(
right: 8,
left: 1,
),
child: VerificationBadgeComp(
contact: user,
),
),
FlameCounterWidget( FlameCounterWidget(
contactId: user.userId, contactId: user.userId,
prefix: true, prefix: true,
), ),
], ],
), ),
subtitle: (alreadyInGroup.contains(user.userId)) subtitle: subtitleWidget,
? Text(context.lang.alreadyInGroup)
: null,
leading: AvatarIcon( leading: AvatarIcon(
contactId: user.userId, contactId: user.userId,
fontSize: 13, fontSize: 13,
@ -247,6 +270,8 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
onTap: () { onTap: () {
toggleSelectedUser(user.userId); toggleSelectedUser(user.userId);
}, },
);
},
), ),
); );
}, },

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.4.2+161 version: 0.4.3+162
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0

View file

@ -27,6 +27,7 @@ import 'schema_v20.dart' as v20;
import 'schema_v21.dart' as v21; import 'schema_v21.dart' as v21;
import 'schema_v22.dart' as v22; import 'schema_v22.dart' as v22;
import 'schema_v23.dart' as v23; import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -78,6 +79,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v22.DatabaseAtV22(db); return v22.DatabaseAtV22(db);
case 23: case 23:
return v23.DatabaseAtV23(db); return v23.DatabaseAtV23(db);
case 24:
return v24.DatabaseAtV24(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@ -107,5 +110,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
21, 21,
22, 22,
23, 23,
24,
]; ];
} }

File diff suppressed because it is too large Load diff