merge labels and shortcuts to contact groups
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-09-01 23:10:04 +02:00
parent 7d2f1d9f6f
commit deb712f21d
56 changed files with 4083 additions and 3584 deletions

View file

@ -47,7 +47,7 @@ class AppEnvironment {
) async {
await destination.create(recursive: true);
final marker = File('${destination.path}/.runtime_storage_migrated_v1');
if (await marker.exists() || !await source.exists()) return;
if (marker.existsSync() || !source.existsSync()) return;
await for (final entity in source.list(followLinks: false)) {
await _copyEntity(entity, destination.path);
@ -62,7 +62,9 @@ class AppEnvironment {
FileSystemEntity entity,
String destinationDirectory,
) async {
final name = entity.uri.pathSegments.where((value) => value.isNotEmpty).last;
final name = entity.uri.pathSegments
.where((value) => value.isNotEmpty)
.last;
final destinationPath = '$destinationDirectory/$name';
if (entity is Directory) {
final destination = Directory(destinationPath);
@ -75,7 +77,7 @@ class AppEnvironment {
if (entity is! File) return;
final destination = File(destinationPath);
if (await destination.exists()) return;
if (destination.existsSync()) return;
final temporary = File('$destinationPath.migrating');
await entity.copy(temporary.path);
await temporary.rename(destination.path);

View file

@ -0,0 +1,295 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/contact_groups.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
part 'contact_groups.dao.g.dart';
@DriftAccessor(tables: [ContactGroups, ContactGroupMembers])
class ContactGroupsDao extends DatabaseAccessor<TwonlyDB>
with _$ContactGroupsDaoMixin {
ContactGroupsDao(super.db);
Stream<List<ContactGroup>> watchAllContactGroups() {
return (select(
contactGroups,
)..orderBy([(t) => OrderingTerm.asc(t.name)])).watch();
}
Stream<List<ContactGroup>> watchShortcutContactGroups() {
return (select(contactGroups)
..where((t) => t.showAsShortcut.equals(true))
..orderBy([(t) => OrderingTerm.desc(t.usageCounter)]))
.watch();
}
Stream<List<ContactGroup>> watchVisibleGroupsForUser(int userId) {
final query =
select(contactGroupMembers).join([
innerJoin(
contactGroups,
contactGroups.id.equalsExp(contactGroupMembers.contactGroupId),
),
])
..where(
contactGroupMembers.userId.equals(userId) &
contactGroups.showAsLabel.equals(true),
)
..orderBy([OrderingTerm.asc(contactGroups.name)]);
return query.map((row) => row.readTable(contactGroups)).watch();
}
Stream<List<ContactGroup>> watchVisibleGroupsForGroup(String groupId) {
final query =
select(contactGroupMembers).join([
innerJoin(
contactGroups,
contactGroups.id.equalsExp(contactGroupMembers.contactGroupId),
),
])
..where(
contactGroupMembers.groupId.equals(groupId) &
contactGroups.showAsLabel.equals(true),
)
..orderBy([OrderingTerm.asc(contactGroups.name)]);
return query.map((row) => row.readTable(contactGroups)).watch();
}
Stream<List<(int, ContactGroup)>> watchAllVisibleUserGroups() {
final query =
select(contactGroupMembers).join([
innerJoin(
contactGroups,
contactGroups.id.equalsExp(contactGroupMembers.contactGroupId),
),
])..where(
contactGroupMembers.userId.isNotNull() &
contactGroups.showAsLabel.equals(true),
);
return query.watch().map(
(rows) => rows
.map(
(row) => (
row.readTable(contactGroupMembers).userId!,
row.readTable(contactGroups),
),
)
.toList(),
);
}
Stream<List<(String, ContactGroup)>> watchAllVisibleChatGroups() {
final query =
select(contactGroupMembers).join([
innerJoin(
contactGroups,
contactGroups.id.equalsExp(contactGroupMembers.contactGroupId),
),
])..where(
contactGroupMembers.groupId.isNotNull() &
contactGroups.showAsLabel.equals(true),
);
return query.watch().map(
(rows) => rows
.map(
(row) => (
row.readTable(contactGroupMembers).groupId!,
row.readTable(contactGroups),
),
)
.toList(),
);
}
Stream<Set<int>> watchContactGroupIdsForUser(int userId) {
return (select(contactGroupMembers)..where((t) => t.userId.equals(userId)))
.watch()
.map((rows) => rows.map((row) => row.contactGroupId).toSet());
}
Stream<Set<int>> watchContactGroupIdsForGroup(String groupId) {
return (select(
contactGroupMembers,
)..where((t) => t.groupId.equals(groupId))).watch().map(
(rows) => rows.map((row) => row.contactGroupId).toSet(),
);
}
Future<ContactGroup?> getContactGroup(int id) {
return (select(
contactGroups,
)..where((t) => t.id.equals(id))).getSingleOrNull();
}
Future<List<ContactGroupMember>> getMembers(int contactGroupId) {
return (select(
contactGroupMembers,
)..where((t) => t.contactGroupId.equals(contactGroupId))).get();
}
Future<int> createContactGroup({
required String name,
required int textColor,
required int backgroundColor,
required bool showAsShortcut,
required bool showAsLabel,
String? emoji,
}) {
return into(contactGroups).insert(
ContactGroupsCompanion.insert(
name: _sanitizeName(name),
emoji: Value(_sanitizeEmoji(emoji)),
textColor: textColor,
backgroundColor: backgroundColor,
showAsShortcut: Value(showAsShortcut),
showAsLabel: Value(showAsLabel),
),
);
}
Future<bool> updateContactGroup({
required int id,
required String name,
required int textColor,
required int backgroundColor,
required bool showAsShortcut,
required bool showAsLabel,
String? emoji,
}) {
return (update(contactGroups)..where((t) => t.id.equals(id)))
.write(
ContactGroupsCompanion(
name: Value(_sanitizeName(name)),
emoji: Value(_sanitizeEmoji(emoji)),
textColor: Value(textColor),
backgroundColor: Value(backgroundColor),
showAsShortcut: Value(showAsShortcut),
showAsLabel: Value(showAsLabel),
),
)
.then((rows) => rows > 0);
}
Future<void> setUserMembership(
int contactGroupId,
int userId,
bool selected,
) async {
await transaction(() async {
await (delete(contactGroupMembers)..where(
(t) =>
t.contactGroupId.equals(contactGroupId) &
t.userId.equals(userId),
))
.go();
if (selected) {
await into(contactGroupMembers).insert(
ContactGroupMembersCompanion.insert(
contactGroupId: contactGroupId,
userId: Value(userId),
),
);
}
});
}
Future<void> setGroupMembership(
int contactGroupId,
String groupId,
bool selected,
) async {
if (selected) {
final group =
await (select(attachedDatabase.groups)..where(
(group) =>
group.groupId.equals(groupId) &
group.isDirectChat.equals(false),
))
.getSingleOrNull();
// Direct chats carry their labels through the contact instead.
if (group == null) return;
}
await transaction(() async {
await (delete(contactGroupMembers)..where(
(t) =>
t.contactGroupId.equals(contactGroupId) &
t.groupId.equals(groupId),
))
.go();
if (selected) {
await into(contactGroupMembers).insert(
ContactGroupMembersCompanion.insert(
contactGroupId: contactGroupId,
groupId: Value(groupId),
),
);
}
});
}
Future<void> replaceMembers(
int contactGroupId, {
required Iterable<int> userIds,
required Iterable<String> groupIds,
}) async {
final requestedUserIds = userIds.toSet();
final requestedGroupIds = groupIds.toSet();
final validGroupIds = requestedGroupIds.isEmpty
? const <String>[]
: await (select(attachedDatabase.groups)..where(
(group) =>
group.groupId.isIn(requestedGroupIds) &
group.isDirectChat.equals(false),
))
.map((group) => group.groupId)
.get();
await transaction(() async {
await (delete(
contactGroupMembers,
)..where((t) => t.contactGroupId.equals(contactGroupId))).go();
final members = [
...requestedUserIds.map(
(userId) => ContactGroupMembersCompanion.insert(
contactGroupId: contactGroupId,
userId: Value(userId),
),
),
...validGroupIds.map(
(groupId) => ContactGroupMembersCompanion.insert(
contactGroupId: contactGroupId,
groupId: Value(groupId),
),
),
];
if (members.isNotEmpty) {
await batch(
(batch) => batch.insertAll(contactGroupMembers, members),
);
}
});
}
Future<void> incrementUsage(int contactGroupId) async {
await customStatement(
'UPDATE contact_groups '
'SET usage_counter = usage_counter + 1 WHERE id = ?',
[contactGroupId],
);
notifyUpdates({
TableUpdate.onTable(contactGroups, kind: UpdateKind.update),
});
}
Future<int> deleteContactGroup(int id) {
return (delete(contactGroups)..where((t) => t.id.equals(id))).go();
}
static String _sanitizeName(String name) {
final trimmed = name.trim();
return trimmed.length > 24 ? trimmed.substring(0, 24) : trimmed;
}
static String? _sanitizeEmoji(String? emoji) {
final trimmed = emoji?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
}

View file

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contact_groups.dao.dart';
// ignore_for_file: type=lint
mixin _$ContactGroupsDaoMixin on DatabaseAccessor<TwonlyDB> {
$ContactGroupsTable get contactGroups => attachedDatabase.contactGroups;
$ContactsTable get contacts => attachedDatabase.contacts;
$GroupsTable get groups => attachedDatabase.groups;
$ContactGroupMembersTable get contactGroupMembers =>
attachedDatabase.contactGroupMembers;
ContactGroupsDaoManager get managers => ContactGroupsDaoManager(this);
}
class ContactGroupsDaoManager {
final _$ContactGroupsDaoMixin _db;
ContactGroupsDaoManager(this._db);
$$ContactGroupsTableTableManager get contactGroups =>
$$ContactGroupsTableTableManager(_db.attachedDatabase, _db.contactGroups);
$$ContactsTableTableManager get contacts =>
$$ContactsTableTableManager(_db.attachedDatabase, _db.contacts);
$$GroupsTableTableManager get groups =>
$$GroupsTableTableManager(_db.attachedDatabase, _db.groups);
$$ContactGroupMembersTableTableManager get contactGroupMembers =>
$$ContactGroupMembersTableTableManager(
_db.attachedDatabase,
_db.contactGroupMembers,
);
}

View file

@ -1,100 +0,0 @@
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();
}
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(),
);
}
Stream<List<(int, Label)>> watchAllContactLabels() {
final query = select(contactLabels).join([
innerJoin(labels, labels.id.equalsExp(contactLabels.labelId)),
]);
return query.watch().map(
(rows) => rows
.map(
(row) => (
row.readTable(contactLabels).contactId,
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

@ -1,22 +0,0 @@
// 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);
}

View file

@ -1,79 +0,0 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/shortcuts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
part 'shortcuts.dao.g.dart';
@DriftAccessor(
tables: [
Shortcuts,
ShortcutMembers,
],
)
class ShortcutsDao extends DatabaseAccessor<TwonlyDB> with _$ShortcutsDaoMixin {
ShortcutsDao(super.db);
Stream<List<Shortcut>> watchAllShortcuts() {
return select(shortcuts).watch();
}
Future<Shortcut?> getShortcutByEmoji(String emoji) {
return (select(
shortcuts,
)..where((t) => t.emoji.equals(emoji))).getSingleOrNull();
}
Future<void> createShortcut(String emoji) async {
try {
await into(shortcuts).insert(
ShortcutsCompanion.insert(emoji: emoji),
);
// ignore: empty_catches
} catch (e) {}
}
Future<void> addShortcutMembers(int shortcutId, List<String> groupIds) async {
await batch((b) {
b.insertAll(
shortcutMembers,
groupIds.map(
(gId) => ShortcutMembersCompanion.insert(
shortcutId: shortcutId,
groupId: gId,
),
),
);
});
}
Future<List<ShortcutMember>> getShortcutMembers(int shortcutId) {
return (select(
shortcutMembers,
)..where((t) => t.shortcutId.equals(shortcutId))).get();
}
Future<void> incrementUsage(int shortcutId) async {
await customStatement(
'UPDATE shortcuts SET usage_counter = usage_counter + 1 WHERE id = ?',
[shortcutId],
);
// Notify updates to trigger streams
notifyUpdates({TableUpdate.onTable(shortcuts, kind: UpdateKind.update)});
}
Future<void> updateShortcut(int shortcutId, String emoji) async {
await (update(shortcuts)..where((t) => t.id.equals(shortcutId))).write(
ShortcutsCompanion(emoji: Value(emoji)),
);
}
Future<void> deleteShortcutMembers(int shortcutId) async {
await (delete(
shortcutMembers,
)..where((t) => t.shortcutId.equals(shortcutId))).go();
}
Future<void> deleteShortcut(int shortcutId) async {
await (delete(shortcuts)..where((t) => t.id.equals(shortcutId))).go();
}
}

View file

@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'shortcuts.dao.dart';
// ignore_for_file: type=lint
mixin _$ShortcutsDaoMixin on DatabaseAccessor<TwonlyDB> {
$ShortcutsTable get shortcuts => attachedDatabase.shortcuts;
$GroupsTable get groups => attachedDatabase.groups;
$ShortcutMembersTable get shortcutMembers => attachedDatabase.shortcutMembers;
ShortcutsDaoManager get managers => ShortcutsDaoManager(this);
}
class ShortcutsDaoManager {
final _$ShortcutsDaoMixin _db;
ShortcutsDaoManager(this._db);
$$ShortcutsTableTableManager get shortcuts =>
$$ShortcutsTableTableManager(_db.attachedDatabase, _db.shortcuts);
$$GroupsTableTableManager get groups =>
$$GroupsTableTableManager(_db.attachedDatabase, _db.groups);
$$ShortcutMembersTableTableManager get shortcutMembers =>
$$ShortcutMembersTableTableManager(
_db.attachedDatabase,
_db.shortcutMembers,
);
}

View file

@ -0,0 +1,48 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
@DataClassName('ContactGroup')
class ContactGroups extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get emoji => text().nullable()();
IntColumn get textColor => integer()();
IntColumn get backgroundColor => integer()();
BoolColumn get showAsShortcut =>
boolean().withDefault(const Constant(false))();
BoolColumn get showAsLabel => boolean().withDefault(const Constant(true))();
IntColumn get usageCounter => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('ContactGroupMember')
class ContactGroupMembers extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get contactGroupId => integer().references(
ContactGroups,
#id,
onDelete: KeyAction.cascade,
)();
IntColumn get userId => integer().nullable().references(
Contacts,
#userId,
onDelete: KeyAction.cascade,
)();
TextColumn get groupId => text().nullable().references(
Groups,
#groupId,
onDelete: KeyAction.cascade,
)();
@override
List<Set<Column<Object>>> get uniqueKeys => [
{contactGroupId, userId},
{contactGroupId, groupId},
];
@override
List<String> get customConstraints => [
'CHECK ((user_id IS NOT NULL) != (group_id IS NOT NULL))',
];
}

View file

@ -1,28 +0,0 @@
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

@ -1,26 +0,0 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
@DataClassName('Shortcut')
class Shortcuts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get emoji => text().unique()();
IntColumn get usageCounter => integer().withDefault(const Constant(0))();
}
@DataClassName('ShortcutMember')
class ShortcutMembers extends Table {
IntColumn get shortcutId => integer().references(
Shortcuts,
#id,
onDelete: KeyAction.cascade,
)();
TextColumn get groupId => text().references(
Groups,
#groupId,
onDelete: KeyAction.cascade,
)();
@override
Set<Column> get primaryKey => {shortcutId, groupId};
}

View file

@ -1,26 +1,24 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:twonly/src/database/daos/contact_groups.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/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/messages.dao.dart';
import 'package:twonly/src/database/daos/reactions.dao.dart';
import 'package:twonly/src/database/daos/receipts.dao.dart';
import 'package:twonly/src/database/daos/shortcuts.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/rust_change_notifier.dart';
import 'package:twonly/src/database/rust_query_executor.dart';
import 'package:twonly/src/database/tables/contact_groups.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/labels.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/reactions.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/tables/shortcuts.table.dart';
import 'package:twonly/src/database/tables/user_discovery.table.dart';
import 'package:twonly/src/database/twonly.db.steps.dart';
import 'package:twonly/src/utils/log.dart';
@ -48,10 +46,8 @@ part 'twonly.db.g.dart';
UserDiscoveryOtherPromotions,
UserDiscoveryOwnPromotions,
UserDiscoveryShares,
Shortcuts,
ShortcutMembers,
Labels,
ContactLabels,
ContactGroups,
ContactGroupMembers,
],
daos: [
MessagesDao,
@ -62,8 +58,7 @@ part 'twonly.db.g.dart';
MediaFilesDao,
UserDiscoveryDao,
KeyVerificationDao,
ShortcutsDao,
LabelsDao,
ContactGroupsDao,
],
)
class TwonlyDB extends _$TwonlyDB {
@ -93,6 +88,8 @@ class TwonlyDB extends _$TwonlyDB {
@override
MigrationStrategy get migration {
// Frozen legacy-Drift upgrade chain used only before Rust imports schema
// v25. All current application schema migrations are owned by Rust.
return MigrationStrategy(
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');

File diff suppressed because it is too large Load diff

View file

@ -3362,48 +3362,12 @@ abstract class AppLocalizations {
/// **'Registering new account'**
String get registeringNewAccount;
/// No description provided for @createShortcut.
///
/// In en, this message translates to:
/// **'Create shortcut'**
String get createShortcut;
/// No description provided for @editShortcut.
///
/// In en, this message translates to:
/// **'Edit shortcut'**
String get editShortcut;
/// No description provided for @deleteShortcut.
///
/// In en, this message translates to:
/// **'Delete shortcut'**
String get deleteShortcut;
/// No description provided for @deleteShortcutBody.
///
/// In en, this message translates to:
/// **'Are you sure you want to delete this shortcut?'**
String get deleteShortcutBody;
/// No description provided for @updateShortcut.
///
/// In en, this message translates to:
/// **'Update shortcut'**
String get updateShortcut;
/// No description provided for @selectEmoji.
///
/// In en, this message translates to:
/// **'Select Emoji'**
String get selectEmoji;
/// No description provided for @errorEmojiUsedOrInvalid.
///
/// In en, this message translates to:
/// **'Emoji already used or invalid'**
String get errorEmojiUsedOrInvalid;
/// No description provided for @subscriptionPledgeSecureTitle.
///
/// In en, this message translates to:
@ -4322,65 +4286,125 @@ abstract class AppLocalizations {
/// **'Show flame restore warning'**
String get settingsShowRestoreFlameTitle;
/// No description provided for @contactLabelsTitle.
/// No description provided for @contactGroupsTitle.
///
/// In en, this message translates to:
/// **'Contact Labels'**
String get contactLabelsTitle;
/// **'Contact Groups'**
String get contactGroupsTitle;
/// No description provided for @contactLabelsSubtitleEmpty.
/// No description provided for @contactGroupsSubtitleEmpty.
///
/// In en, this message translates to:
/// **'No labels selected'**
String get contactLabelsSubtitleEmpty;
/// **'No visible contact groups selected'**
String get contactGroupsSubtitleEmpty;
/// No description provided for @contactLabelsMaxLimit.
/// No description provided for @createContactGroup.
///
/// In en, this message translates to:
/// **'Maximum 3 labels per contact'**
String get contactLabelsMaxLimit;
/// **'Create contact group'**
String get createContactGroup;
/// No description provided for @createLabel.
/// No description provided for @editContactGroup.
///
/// In en, this message translates to:
/// **'Create new label'**
String get createLabel;
/// **'Edit contact group'**
String get editContactGroup;
/// No description provided for @editLabel.
/// No description provided for @deleteContactGroup.
///
/// In en, this message translates to:
/// **'Edit label'**
String get editLabel;
/// **'Delete contact group'**
String get deleteContactGroup;
/// No description provided for @deleteLabel.
/// No description provided for @deleteContactGroupConfirmation.
///
/// In en, this message translates to:
/// **'Delete label'**
String get deleteLabel;
/// **'Are you sure you want to delete this contact group? It will be removed from all contacts and groups.'**
String get deleteContactGroupConfirmation;
/// 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.
/// No description provided for @contactGroupTextColor.
///
/// In en, this message translates to:
/// **'Text color'**
String get labelTextColor;
String get contactGroupTextColor;
/// No description provided for @labelBackgroundColor.
/// No description provided for @contactGroupBackgroundColor.
///
/// In en, this message translates to:
/// **'Background color'**
String get labelBackgroundColor;
String get contactGroupBackgroundColor;
/// No description provided for @contactGroupNoBackground.
///
/// In en, this message translates to:
/// **'No background'**
String get contactGroupNoBackground;
/// No description provided for @contactGroupFeatures.
///
/// In en, this message translates to:
/// **'Features'**
String get contactGroupFeatures;
/// No description provided for @contactGroupShowAsLabel.
///
/// In en, this message translates to:
/// **'Show as visible label'**
String get contactGroupShowAsLabel;
/// No description provided for @contactGroupShowAsLabelSubtitle.
///
/// In en, this message translates to:
/// **'Display this contact group next to its contacts.'**
String get contactGroupShowAsLabelSubtitle;
/// No description provided for @contactGroupShowAsShortcut.
///
/// In en, this message translates to:
/// **'Show as shortcut'**
String get contactGroupShowAsShortcut;
/// No description provided for @contactGroupShowAsShortcutSubtitle.
///
/// In en, this message translates to:
/// **'Use its emoji to select all members when sharing.'**
String get contactGroupShowAsShortcutSubtitle;
/// No description provided for @contactGroupShortcutNeedsEmoji.
///
/// In en, this message translates to:
/// **'Select an emoji before enabling the shortcut.'**
String get contactGroupShortcutNeedsEmoji;
/// No description provided for @contactGroupMembers.
///
/// In en, this message translates to:
/// **'Members'**
String get contactGroupMembers;
/// No description provided for @contactGroupLabelFeature.
///
/// In en, this message translates to:
/// **'Visible label'**
String get contactGroupLabelFeature;
/// No description provided for @contactGroupShortcutFeature.
///
/// In en, this message translates to:
/// **'Shortcut'**
String get contactGroupShortcutFeature;
/// No description provided for @contactGroupSettings.
///
/// In en, this message translates to:
/// **'Edit contact group'**
String get contactGroupSettings;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @customColor.
///

View file

@ -1936,29 +1936,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get registeringNewAccount => 'Neues Konto wird registriert';
@override
String get createShortcut => 'Shortcut erstellen';
@override
String get editShortcut => 'Shortcut bearbeiten';
@override
String get deleteShortcut => 'Shortcut löschen';
@override
String get deleteShortcutBody =>
'Bist du sicher, dass du diesen Shortcut löschen möchtest?';
@override
String get updateShortcut => 'Shortcut aktualisieren';
@override
String get selectEmoji => 'Emoji auswählen';
@override
String get errorEmojiUsedOrInvalid =>
'Emoji wird bereits verwendet oder ist ungültig';
@override
String get subscriptionPledgeSecureTitle => 'Secure by Design';
@ -2506,35 +2486,69 @@ class AppLocalizationsDe extends AppLocalizations {
'Hinweis zur Flammen-Wiederherstellung anzeigen';
@override
String get contactLabelsTitle => 'Kontaktlabels';
String get contactGroupsTitle => 'Kontaktgruppen';
@override
String get contactLabelsSubtitleEmpty => 'Keine Labels ausgewählt';
String get contactGroupsSubtitleEmpty =>
'Keine sichtbaren Kontaktgruppen ausgewählt';
@override
String get contactLabelsMaxLimit => 'Maximal 3 Labels pro Kontakt';
String get createContactGroup => 'Kontaktgruppe erstellen';
@override
String get createLabel => 'Neues Label erstellen';
String get editContactGroup => 'Kontaktgruppe anpassen';
@override
String get editLabel => 'Label bearbeiten';
String get deleteContactGroup => 'Kontaktgruppe löschen';
@override
String get deleteLabel => 'Label löschen';
String get deleteContactGroupConfirmation =>
'Möchtest du diese Kontaktgruppe wirklich löschen? Sie wird von allen Kontakten und Gruppen entfernt.';
@override
String get deleteLabelConfirmation =>
'Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.';
String get contactGroupTextColor => 'Textfarbe';
@override
String get labelNameHint => 'Label-Name';
String get contactGroupBackgroundColor => 'Hintergrundfarbe';
@override
String get labelTextColor => 'Textfarbe';
String get contactGroupNoBackground => 'Kein Hintergrund';
@override
String get labelBackgroundColor => 'Hintergrundfarbe';
String get contactGroupFeatures => 'Funktionen';
@override
String get contactGroupShowAsLabel => 'Als sichtbares Label anzeigen';
@override
String get contactGroupShowAsLabelSubtitle =>
'Zeigt diese Kontaktgruppe neben den zugehörigen Kontakten an.';
@override
String get contactGroupShowAsShortcut => 'Als Shortcut anzeigen';
@override
String get contactGroupShowAsShortcutSubtitle =>
'Wählt beim Teilen über das Emoji alle Mitglieder aus.';
@override
String get contactGroupShortcutNeedsEmoji =>
'Wähle ein Emoji aus, bevor du den Shortcut aktivierst.';
@override
String get contactGroupMembers => 'Mitglieder';
@override
String get contactGroupLabelFeature => 'Sichtbares Label';
@override
String get contactGroupShortcutFeature => 'Shortcut';
@override
String get contactGroupSettings => 'Kontaktgruppe anpassen';
@override
String get save => 'Speichern';
@override
String get customColor => 'Eigene Farbe';

View file

@ -1922,28 +1922,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get registeringNewAccount => 'Registering new account';
@override
String get createShortcut => 'Create shortcut';
@override
String get editShortcut => 'Edit shortcut';
@override
String get deleteShortcut => 'Delete shortcut';
@override
String get deleteShortcutBody =>
'Are you sure you want to delete this shortcut?';
@override
String get updateShortcut => 'Update shortcut';
@override
String get selectEmoji => 'Select Emoji';
@override
String get errorEmojiUsedOrInvalid => 'Emoji already used or invalid';
@override
String get subscriptionPledgeSecureTitle => 'Secure by Design';
@ -2481,35 +2462,68 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsShowRestoreFlameTitle => 'Show flame restore warning';
@override
String get contactLabelsTitle => 'Contact Labels';
String get contactGroupsTitle => 'Contact Groups';
@override
String get contactLabelsSubtitleEmpty => 'No labels selected';
String get contactGroupsSubtitleEmpty => 'No visible contact groups selected';
@override
String get contactLabelsMaxLimit => 'Maximum 3 labels per contact';
String get createContactGroup => 'Create contact group';
@override
String get createLabel => 'Create new label';
String get editContactGroup => 'Edit contact group';
@override
String get editLabel => 'Edit label';
String get deleteContactGroup => 'Delete contact group';
@override
String get deleteLabel => 'Delete label';
String get deleteContactGroupConfirmation =>
'Are you sure you want to delete this contact group? It will be removed from all contacts and groups.';
@override
String get deleteLabelConfirmation =>
'Are you sure you want to delete this label? It will be removed from all contacts.';
String get contactGroupTextColor => 'Text color';
@override
String get labelNameHint => 'Label name';
String get contactGroupBackgroundColor => 'Background color';
@override
String get labelTextColor => 'Text color';
String get contactGroupNoBackground => 'No background';
@override
String get labelBackgroundColor => 'Background color';
String get contactGroupFeatures => 'Features';
@override
String get contactGroupShowAsLabel => 'Show as visible label';
@override
String get contactGroupShowAsLabelSubtitle =>
'Display this contact group next to its contacts.';
@override
String get contactGroupShowAsShortcut => 'Show as shortcut';
@override
String get contactGroupShowAsShortcutSubtitle =>
'Use its emoji to select all members when sharing.';
@override
String get contactGroupShortcutNeedsEmoji =>
'Select an emoji before enabling the shortcut.';
@override
String get contactGroupMembers => 'Members';
@override
String get contactGroupLabelFeature => 'Visible label';
@override
String get contactGroupShortcutFeature => 'Shortcut';
@override
String get contactGroupSettings => 'Edit contact group';
@override
String get save => 'Save';
@override
String get customColor => 'Custom Color';

View file

@ -687,13 +687,7 @@
"registerNewAccount": "Neues Konto registrieren",
"tryRestoreAgain": "Wiederherstellung erneut versuchen",
"registeringNewAccount": "Neues Konto wird registriert",
"createShortcut": "Shortcut erstellen",
"editShortcut": "Shortcut bearbeiten",
"deleteShortcut": "Shortcut löschen",
"deleteShortcutBody": "Bist du sicher, dass du diesen Shortcut löschen möchtest?",
"updateShortcut": "Shortcut aktualisieren",
"selectEmoji": "Emoji auswählen",
"errorEmojiUsedOrInvalid": "Emoji wird bereits verwendet oder ist ungültig",
"subscriptionPledgeSecureTitle": "Secure by Design",
"subscriptionPledgeSecureDesc": "Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.",
"subscriptionPledgeNoAdsTitle": "Keine Werbung oder Datenverkauf",
@ -937,16 +931,26 @@
}
},
"settingsShowRestoreFlameTitle": "Hinweis zur Flammen-Wiederherstellung anzeigen",
"contactLabelsTitle": "Kontaktlabels",
"contactLabelsSubtitleEmpty": "Keine Labels ausgewählt",
"contactLabelsMaxLimit": "Maximal 3 Labels pro Kontakt",
"createLabel": "Neues Label erstellen",
"editLabel": "Label bearbeiten",
"deleteLabel": "Label löschen",
"deleteLabelConfirmation": "Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.",
"labelNameHint": "Label-Name",
"labelTextColor": "Textfarbe",
"labelBackgroundColor": "Hintergrundfarbe",
"contactGroupsTitle": "Kontaktgruppen",
"contactGroupsSubtitleEmpty": "Keine sichtbaren Kontaktgruppen ausgewählt",
"createContactGroup": "Kontaktgruppe erstellen",
"editContactGroup": "Kontaktgruppe anpassen",
"deleteContactGroup": "Kontaktgruppe löschen",
"deleteContactGroupConfirmation": "Möchtest du diese Kontaktgruppe wirklich löschen? Sie wird von allen Kontakten und Gruppen entfernt.",
"contactGroupTextColor": "Textfarbe",
"contactGroupBackgroundColor": "Hintergrundfarbe",
"contactGroupNoBackground": "Kein Hintergrund",
"contactGroupFeatures": "Funktionen",
"contactGroupShowAsLabel": "Als sichtbares Label anzeigen",
"contactGroupShowAsLabelSubtitle": "Zeigt diese Kontaktgruppe neben den zugehörigen Kontakten an.",
"contactGroupShowAsShortcut": "Als Shortcut anzeigen",
"contactGroupShowAsShortcutSubtitle": "Wählt beim Teilen über das Emoji alle Mitglieder aus.",
"contactGroupShortcutNeedsEmoji": "Wähle ein Emoji aus, bevor du den Shortcut aktivierst.",
"contactGroupMembers": "Mitglieder",
"contactGroupLabelFeature": "Sichtbares Label",
"contactGroupShortcutFeature": "Shortcut",
"contactGroupSettings": "Kontaktgruppe anpassen",
"save": "Speichern",
"customColor": "Eigene Farbe",
"hue": "Farbton",
"saturation": "Sättigung",

View file

@ -697,13 +697,7 @@
"registerNewAccount": "Register New Account",
"tryRestoreAgain": "Try Restore Again",
"registeringNewAccount": "Registering new account",
"createShortcut": "Create shortcut",
"editShortcut": "Edit shortcut",
"deleteShortcut": "Delete shortcut",
"deleteShortcutBody": "Are you sure you want to delete this shortcut?",
"updateShortcut": "Update shortcut",
"selectEmoji": "Select Emoji",
"errorEmojiUsedOrInvalid": "Emoji already used or invalid",
"subscriptionPledgeSecureTitle": "Secure by Design",
"subscriptionPledgeSecureDesc": "Your messages and shared moments are fully end-to-end encrypted.",
"subscriptionPledgeNoAdsTitle": "No Ads or Data selling",
@ -947,16 +941,26 @@
}
},
"settingsShowRestoreFlameTitle": "Show flame restore warning",
"contactLabelsTitle": "Contact Labels",
"contactLabelsSubtitleEmpty": "No labels selected",
"contactLabelsMaxLimit": "Maximum 3 labels per contact",
"createLabel": "Create new label",
"editLabel": "Edit label",
"deleteLabel": "Delete label",
"deleteLabelConfirmation": "Are you sure you want to delete this label? It will be removed from all contacts.",
"labelNameHint": "Label name",
"labelTextColor": "Text color",
"labelBackgroundColor": "Background color",
"contactGroupsTitle": "Contact Groups",
"contactGroupsSubtitleEmpty": "No visible contact groups selected",
"createContactGroup": "Create contact group",
"editContactGroup": "Edit contact group",
"deleteContactGroup": "Delete contact group",
"deleteContactGroupConfirmation": "Are you sure you want to delete this contact group? It will be removed from all contacts and groups.",
"contactGroupTextColor": "Text color",
"contactGroupBackgroundColor": "Background color",
"contactGroupNoBackground": "No background",
"contactGroupFeatures": "Features",
"contactGroupShowAsLabel": "Show as visible label",
"contactGroupShowAsLabelSubtitle": "Display this contact group next to its contacts.",
"contactGroupShowAsShortcut": "Show as shortcut",
"contactGroupShowAsShortcutSubtitle": "Use its emoji to select all members when sharing.",
"contactGroupShortcutNeedsEmoji": "Select an emoji before enabling the shortcut.",
"contactGroupMembers": "Members",
"contactGroupLabelFeature": "Visible label",
"contactGroupShortcutFeature": "Shortcut",
"contactGroupSettings": "Edit contact group",
"save": "Save",
"customColor": "Custom Color",
"hue": "Hue",
"saturation": "Saturation",

View file

@ -0,0 +1,279 @@
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';
const noContactGroupBackgroundColor = 0x00000000;
bool contactGroupHasBackground(int backgroundColor) =>
backgroundColor & 0xFF000000 != noContactGroupBackgroundColor;
double contactGroupFontSize(double baseFontSize, int backgroundColor) =>
contactGroupHasBackground(backgroundColor)
? baseFontSize
: baseFontSize * 1.5;
class ContactGroupBadges extends StatefulWidget {
const ContactGroupBadges({
this.userId,
this.groupId,
this.fontSize = 8,
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
this.emptyText,
this.showEmptyText = false,
this.contactGroups,
super.key,
}) : assert(
userId != null || groupId != null || contactGroups != null,
'pass a userId, a groupId or the contact groups themselves',
);
final int? userId;
final String? groupId;
final double fontSize;
final EdgeInsetsGeometry padding;
final String? emptyText;
final bool showEmptyText;
final List<ContactGroup>? contactGroups;
@override
State<ContactGroupBadges> createState() => _ContactGroupBadgesState();
}
class _ContactGroupBadgesState extends State<ContactGroupBadges> {
List<ContactGroup> _contactGroups = [];
StreamSubscription<List<ContactGroup>>? _subscription;
@override
void initState() {
super.initState();
if (widget.contactGroups != null) {
_contactGroups = widget.contactGroups!;
} else {
final groupId = widget.groupId;
_subscription =
(groupId != null
? twonlyDB.contactGroupsDao.watchVisibleGroupsForGroup(
groupId,
)
: twonlyDB.contactGroupsDao.watchVisibleGroupsForUser(
widget.userId!,
))
.listen((contactGroups) {
if (mounted) setState(() => _contactGroups = contactGroups);
});
}
}
@override
void didUpdateWidget(ContactGroupBadges oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contactGroups != null &&
widget.contactGroups != oldWidget.contactGroups) {
_contactGroups = widget.contactGroups!;
}
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_contactGroups.isEmpty) {
if (!widget.showEmptyText && widget.emptyText == null) {
return const SizedBox.shrink();
}
return Text(
widget.emptyText ?? context.lang.contactGroupsSubtitleEmpty,
style: TextStyle(
fontSize: widget.fontSize,
color: Theme.of(context).disabledColor,
),
);
}
return _BadgeMarquee(
children: _contactGroups.map((contactGroup) {
final hasBackground = contactGroupHasBackground(
contactGroup.backgroundColor,
);
return Container(
padding: hasBackground ? widget.padding : EdgeInsets.zero,
decoration: hasBackground
? BoxDecoration(
color: Color(contactGroup.backgroundColor),
borderRadius: BorderRadius.circular(8),
)
: null,
child: Text(
contactGroup.name,
style: TextStyle(
fontSize: contactGroupFontSize(
widget.fontSize,
contactGroup.backgroundColor,
),
color: Color(contactGroup.textColor),
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
);
}
}
/// Lays the badges out on a single line. When they do not fit the available
/// width they slowly scroll from right to left instead of wrapping or
/// overflowing.
class _BadgeMarquee extends StatefulWidget {
const _BadgeMarquee({required this.children});
final List<Widget> children;
@override
State<_BadgeMarquee> createState() => _BadgeMarqueeState();
}
class _BadgeMarqueeState extends State<_BadgeMarquee> {
/// Logical pixels per second the badges travel.
static const _speed = 15.0;
static const _pause = Duration(milliseconds: 1500);
final ScrollController _controller = ScrollController();
bool _scrolling = false;
@override
void initState() {
super.initState();
_scheduleScroll();
}
@override
void didUpdateWidget(_BadgeMarquee oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.children.length != oldWidget.children.length) {
_scheduleScroll();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _scheduleScroll() {
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_scroll()));
}
Future<void> _scroll() async {
if (_scrolling) return;
_scrolling = true;
try {
while (mounted && _controller.hasClients) {
final distance = _controller.position.maxScrollExtent;
if (distance <= 0) return;
final duration = Duration(
milliseconds: (distance / _speed * 1000).round(),
);
if (!await _wait(_pause)) return;
await _controller.animateTo(
distance,
duration: duration,
curve: Curves.linear,
);
if (!await _wait(_pause)) return;
if (!mounted || !_controller.hasClients) return;
await _controller.animateTo(
0,
duration: duration,
curve: Curves.linear,
);
}
} finally {
_scrolling = false;
}
}
/// Waits and reports whether the marquee may keep running afterwards.
Future<bool> _wait(Duration duration) async {
await Future<void>.delayed(duration);
return mounted && _controller.hasClients;
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < widget.children.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
widget.children[i],
],
],
),
);
}
}
class ContactGroupsSubtitleBuilder extends StatelessWidget {
const ContactGroupsSubtitleBuilder({
required this.builder,
this.userId,
this.groupId,
this.additionalSubtitle,
super.key,
}) : assert(
userId != null || groupId != null,
'pass either a userId or a groupId',
);
final int? userId;
final String? groupId;
final Widget? additionalSubtitle;
final Widget Function(BuildContext context, Widget? subtitleWidget) builder;
@override
Widget build(BuildContext context) {
return StreamBuilder<List<ContactGroup>>(
stream: groupId != null
? twonlyDB.contactGroupsDao.watchVisibleGroupsForGroup(groupId!)
: twonlyDB.contactGroupsDao.watchVisibleGroupsForUser(userId!),
builder: (context, snapshot) {
final contactGroups = snapshot.data ?? const [];
Widget? subtitle;
if (additionalSubtitle != null) {
subtitle = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
additionalSubtitle!,
if (contactGroups.isNotEmpty)
ContactGroupBadges(
userId: userId,
groupId: groupId,
contactGroups: contactGroups,
),
],
);
} else if (contactGroups.isNotEmpty) {
subtitle = ContactGroupBadges(
userId: userId,
groupId: groupId,
contactGroups: contactGroups,
);
}
return builder(context, subtitle);
},
);
}
}

View file

@ -1,157 +0,0 @@
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,
this.labels,
super.key,
});
final int contactId;
final double fontSize;
final EdgeInsetsGeometry padding;
final String? emptyText;
final bool showEmptyText;
final List<Label>? labels;
@override
State<ContactLabels> createState() => _ContactLabelsState();
}
class _ContactLabelsState extends State<ContactLabels> {
List<Label> _labels = [];
late StreamSubscription<List<Label>> _sub;
@override
void initState() {
super.initState();
if (widget.labels != null) {
_labels = widget.labels!;
} else {
_sub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen((
labels,
) {
if (mounted) {
setState(() {
_labels = labels;
});
}
});
}
}
@override
void didUpdateWidget(ContactLabels oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.labels != null && widget.labels != oldWidget.labels) {
_labels = widget.labels!;
}
}
@override
void dispose() {
if (widget.labels == null) _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

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
@ -21,79 +23,74 @@ class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
@override
void initState() {
super.initState();
_hsvColor = HSVColor.fromColor(widget.initialColor);
final initialHsvColor = HSVColor.fromColor(widget.initialColor);
final hasVisibleInitialColor =
widget.initialColor.toARGB32() & 0xFF000000 != 0;
_hsvColor =
(hasVisibleInitialColor
? initialHsvColor
: initialHsvColor.withValue(1))
.withAlpha(1);
}
void _selectColor(Offset position, Size size) {
const indicatorRadius = 12.0;
final center = size.center(Offset.zero);
final wheelRadius = size.shortestSide / 2 - indicatorRadius;
final offset = position - center;
final distance = offset.distance;
final saturation = (distance / wheelRadius).clamp(0.0, 1.0);
final hue = distance < 1
? _hsvColor.hue
: (math.atan2(offset.dy, offset.dx) * 180 / math.pi + 360) % 360;
setState(() {
_hsvColor = HSVColor.fromAHSV(
_hsvColor.alpha,
hue,
saturation,
_hsvColor.value,
);
});
}
@override
Widget build(BuildContext context) {
final currentColor = _hsvColor.toColor();
final availableWidth = MediaQuery.sizeOf(context).width - 96;
final wheelSize = math.max<double>(
120,
math.min<double>(240, availableWidth),
);
final colorWheelSize = Size.square(wheelSize);
return AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
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),
),
],
child: SizedBox.square(
dimension: wheelSize,
child: Semantics(
label: context.lang.customColor,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (details) =>
_selectColor(details.localPosition, colorWheelSize),
onPanUpdate: (details) =>
_selectColor(details.localPosition, colorWheelSize),
onTapUp: (details) =>
_selectColor(details.localPosition, colorWheelSize),
child: CustomPaint(
painter: _ColorWheelPainter(hsvColor: _hsvColor),
),
),
),
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);
});
},
),
],
),
@ -123,3 +120,83 @@ class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
);
}
}
class _ColorWheelPainter extends CustomPainter {
const _ColorWheelPainter({required this.hsvColor});
final HSVColor hsvColor;
static const _indicatorRadius = 12.0;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide / 2 - _indicatorRadius;
final wheelRect = Rect.fromCircle(center: center, radius: radius);
canvas
..save()
..clipPath(Path()..addOval(wheelRect))
..drawCircle(
center,
radius,
Paint()
..shader = const SweepGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
).createShader(wheelRect),
)
..drawCircle(
center,
radius,
Paint()
..shader = const RadialGradient(
colors: [Colors.white, Colors.transparent],
).createShader(wheelRect),
);
if (hsvColor.value < 1) {
canvas.drawCircle(
center,
radius,
Paint()..color = Colors.black.withValues(alpha: 1 - hsvColor.value),
);
}
canvas.restore();
final angle = hsvColor.hue * math.pi / 180;
final indicatorCenter =
center +
Offset(math.cos(angle), math.sin(angle)) * hsvColor.saturation * radius;
final selectedColor = hsvColor.withAlpha(1).toColor();
canvas
..drawCircle(
indicatorCenter,
_indicatorRadius,
Paint()..color = Colors.black.withValues(alpha: 0.3),
)
..drawCircle(
indicatorCenter,
_indicatorRadius - 2,
Paint()..color = Colors.white,
)
..drawCircle(
indicatorCenter,
_indicatorRadius - 5,
Paint()..color = selectedColor,
);
}
@override
bool shouldRepaint(covariant _ColorWheelPainter oldDelegate) =>
oldDelegate.hsvColor != hsvColor;
}

View file

@ -1,352 +0,0 @@
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

@ -9,6 +9,13 @@ class MediaViewSizingHelper extends StatefulWidget {
this.additionalPadding,
});
const MediaViewSizingHelper.cameraEditor({
required this.child,
required this.bottomNavigation,
super.key,
}) : requiredHeight = 59,
additionalPadding = null;
final double? requiredHeight;
final double? additionalPadding;
final Widget? bottomNavigation;
@ -21,37 +28,19 @@ class MediaViewSizingHelper extends StatefulWidget {
class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
@override
Widget build(BuildContext context) {
var needToDownSizeImage = false;
// Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes
final screenSize = MediaQuery.sizeOf(context);
final safeAreaPadding = MediaQuery.paddingOf(context);
// Calculate the available width and height
final availableWidth = screenSize.width;
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final availableHeight =
screenSize.height -
safeAreaPadding.top -
safeAreaPadding.bottom -
(widget.additionalPadding ?? 0);
final aspectRatioWidth = availableWidth;
final aspectRatioHeight = (aspectRatioWidth * 16) / 9;
if (aspectRatioHeight > availableHeight) {
needToDownSizeImage = true;
}
if (widget.requiredHeight != null) {
if (aspectRatioHeight < availableHeight) {
if ((screenSize.height - widget.requiredHeight!) < aspectRatioHeight) {
needToDownSizeImage = true;
}
}
}
constraints.maxHeight - (widget.additionalPadding ?? 0);
final aspectRatioHeight = (availableWidth * 16) / 9;
final bottomNavigationHeight = widget.requiredHeight ?? 0;
final needToDownSizeImage =
aspectRatioHeight + bottomNavigationHeight > availableHeight;
Widget imageChild = Align(
alignment: Alignment.topCenter,
child: SizedBox(
// height: availableHeight,
child: AspectRatio(
aspectRatio: 9 / 16,
child: ClipRRect(
@ -59,10 +48,9 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
child: widget.child,
),
),
),
);
Widget bottomNavigation = Container();
Widget bottomNavigation = const SizedBox.shrink();
if (widget.bottomNavigation != null) {
if (needToDownSizeImage) {
@ -76,14 +64,13 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
}
}
return SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: availableHeight,
),
return Container(
constraints: BoxConstraints(maxHeight: availableHeight),
child: Column(
children: [imageChild, bottomNavigation],
),
);
},
),
);
}

View file

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
const defaultPrimaryColor = Color(0xFF57CC99);
const defaultPrimaryColor = Color(0xFF32be80);
ThemeData getLightTheme([Color primary = defaultPrimaryColor]) {
final base = ThemeData(

View file

@ -1,293 +0,0 @@
import 'dart:async';
import 'dart:collection';
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/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
class AddNewShortcutView extends StatefulWidget {
const AddNewShortcutView({this.shortcut, super.key});
final Shortcut? shortcut;
@override
State<AddNewShortcutView> createState() => _StartNewChatView();
}
class _StartNewChatView extends State<AddNewShortcutView> {
List<Group> _groups = [];
List<Group> _allGroups = [];
final TextEditingController _searchGroupName = TextEditingController();
late StreamSubscription<List<Group>> _groupSub;
final HashSet<String> _selectedGroups = HashSet();
String? shortcutEmoji;
@override
void initState() {
super.initState();
if (widget.shortcut != null) {
shortcutEmoji = widget.shortcut!.emoji;
twonlyDB.shortcutsDao.getShortcutMembers(widget.shortcut!.id).then((
members,
) {
if (mounted) {
setState(() {
for (final m in members) {
_selectedGroups.add(m.groupId);
}
});
}
});
}
final stream = twonlyDB.groupsDao.watchGroupsForChatList();
_groupSub = stream.listen((update) async {
update.sort(
(a, b) => a.groupName.compareTo(b.groupName),
);
setState(() {
_allGroups = update;
});
await filterUsers();
});
}
@override
void dispose() {
unawaited(_groupSub.cancel());
super.dispose();
}
Future<void> filterUsers() async {
if (_searchGroupName.value.text.isEmpty) {
setState(() {
_groups = _allGroups;
});
return;
}
final usersFiltered = _allGroups
.where(
(group) => group.groupName.toLowerCase().contains(
_searchGroupName.value.text.toLowerCase(),
),
)
.toList();
setState(() {
_groups = usersFiltered;
});
}
void toggleSelectedGroup(String groupId) {
if (!_selectedGroups.contains(groupId)) {
if (_selectedGroups.length > 256) {
showSnackbar(context, context.lang.groupSizeLimitError(256));
return;
}
_selectedGroups.add(groupId);
} else {
_selectedGroups.remove(groupId);
}
setState(() {});
}
Future<void> submitChanges() async {
try {
if (widget.shortcut != null) {
await twonlyDB.shortcutsDao.updateShortcut(
widget.shortcut!.id,
shortcutEmoji!,
);
await twonlyDB.shortcutsDao.deleteShortcutMembers(widget.shortcut!.id);
await twonlyDB.shortcutsDao.addShortcutMembers(
widget.shortcut!.id,
_selectedGroups.toList(),
);
} else {
await twonlyDB.shortcutsDao.createShortcut(
shortcutEmoji!,
);
final shortcutId = (await twonlyDB.shortcutsDao.getShortcutByEmoji(
shortcutEmoji!,
))!.id;
await twonlyDB.shortcutsDao.deleteShortcutMembers(shortcutId);
await twonlyDB.shortcutsDao.addShortcutMembers(
shortcutId,
_selectedGroups.toList(),
);
}
if (mounted) Navigator.pop(context);
} catch (e) {
Log.error(e);
if (mounted) {
showSnackbar(context, context.lang.errorEmojiUsedOrInvalid);
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: Text(
widget.shortcut == null
? context.lang.createShortcut
: context.lang.editShortcut,
),
actions: [
if (widget.shortcut != null)
IconButton(
icon: const FaIcon(
FontAwesomeIcons.trashCan,
size: 18,
color: Colors.red,
),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.lang.deleteShortcut),
content: Text(context.lang.deleteShortcutBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.delete),
),
],
),
);
if (confirm == true) {
await twonlyDB.shortcutsDao.deleteShortcut(
widget.shortcut!.id,
);
if (context.mounted) Navigator.pop(context);
}
},
),
TextButton(
onPressed: () async {
// ignore: inference_failure_on_function_invocation
final result = await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
builder: (context) => const EmojiPickerBottom(),
);
if (result is EmojiLayerData) {
setState(() {
shortcutEmoji = result.text;
});
}
},
child: Text(
shortcutEmoji ?? context.lang.selectEmoji,
style: TextStyle(
fontSize: shortcutEmoji == null ? 14 : 22,
),
),
),
const SizedBox(width: 8),
],
),
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
floatingActionButton: FilledButton.icon(
onPressed: (_selectedGroups.isEmpty || shortcutEmoji == null)
? null
: submitChanges,
label: Text(
widget.shortcut == null
? context.lang.createShortcut
: context.lang.updateShortcut,
),
icon: const FaIcon(FontAwesomeIcons.check),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(
bottom: 40,
left: 10,
top: 20,
right: 10,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField(
onChanged: (_) async {
await filterUsers();
},
controller: _searchGroupName,
decoration: getInputDecoration(
context,
context.lang.shareImageSearchAllContacts,
),
),
),
const SizedBox(height: 10),
Expanded(
child: ListView.builder(
restorationId: 'new_message_users_list',
itemCount: _groups.length,
itemBuilder: (context, i) {
final group = _groups[i];
return ListTile(
key: ValueKey(group.groupId),
title: Row(
children: [
Text(substringBy(group.groupName, 12)),
FlameCounterWidget(
groupId: group.groupId,
prefix: true,
),
],
),
leading: AvatarIcon(
group: group,
fontSize: 15,
),
trailing: Checkbox.adaptive(
value: _selectedGroups.contains(group.groupId),
side: WidgetStateBorderSide.resolveWith(
(states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(width: 0);
}
return BorderSide(
color: Theme.of(context).colorScheme.outline,
);
},
),
onChanged: (value) {
toggleSelectedGroup(group.groupId);
},
),
onTap: () {
toggleSelectedGroup(group.groupId);
},
);
},
),
),
],
),
),
),
),
);
}
}

View file

@ -19,10 +19,8 @@ class MainCameraPreview extends StatelessWidget {
return Container();
}
return Positioned.fill(
child: MediaViewSizingHelper(
requiredHeight: 0,
additionalPadding: 59,
bottomNavigation: Container(),
child: MediaViewSizingHelper.cameraEditor(
bottomNavigation: const SizedBox.shrink(),
child: Stack(
children: [
Screenshot(

View file

@ -8,7 +8,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart';
@ -35,7 +34,6 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/send_to
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_budget.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_time.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
import 'package:twonly/src/visual/views/home.view.dart';
class SelectedCameraDetails {
@ -764,10 +762,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
return StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
return MediaViewSizingHelper(
requiredHeight: 0,
additionalPadding: 59,
bottomNavigation: Container(),
return MediaViewSizingHelper.cameraEditor(
bottomNavigation: const SizedBox.shrink(),
child: GestureDetector(
onPanStart: (details) async {
setState(() {
@ -891,19 +887,6 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
currentTime: _currentTime,
maxRecordingTime: _currentMaxRecordingTime,
),
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
widget.hideControllers)
Positioned(
left: 5,
top: 10,
child: ActionButton(
FontAwesomeIcons.xmark,
tooltipText: context.lang.close,
onPressed: () async {
Navigator.pop(context);
},
),
),
if (_showSelfieFlash) const CameraSelfieFlash(),
CameraScannedOverlay(mainController: mc),
],

View file

@ -68,7 +68,14 @@ class MainCameraController {
GlobalKey cameraPreviewKey = GlobalKey();
bool isSelectingFaceFilters = false;
bool isSharePreviewIsShown = false;
bool _isSharePreviewIsShown = false;
bool get isSharePreviewIsShown => _isSharePreviewIsShown;
set isSharePreviewIsShown(bool value) {
if (_isSharePreviewIsShown == value) return;
_isSharePreviewIsShown = value;
setState?.call();
}
bool isVideoRecording = false;
DateTime? timeSharedLinkWasSetWithQr;

View file

@ -63,8 +63,10 @@ class CameraSendToViewState extends State<CameraSendToView> {
isVisible: true,
),
),
// Always-visible back button so the user can exit regardless of
// camera state (e.g. during init or after init failure).
// Keep this route's close button available during camera startup and
// failures, but hide it beneath the transparent editor, which has
// its own close action.
if (!_mainCameraController.isSharePreviewIsShown)
Positioned(
left: 5,
top: MediaQuery.paddingOf(context).top + 10,

View file

@ -19,7 +19,7 @@ import 'package:twonly/src/visual/elements/headline.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/background.layer.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
@ -188,7 +188,7 @@ class _ShareImageView extends State<ShareImageView> {
),
),
const SizedBox(height: 10),
ShortcutRowComp(
ContactGroupShortcutRow(
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
),

View file

@ -0,0 +1,113 @@
import 'dart:async';
import 'dart:collection';
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';
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
class ContactGroupShortcutRow extends StatefulWidget {
const ContactGroupShortcutRow({
required this.selectedGroupIds,
required this.updateSelectedGroupIds,
super.key,
});
final HashSet<String> selectedGroupIds;
final void Function(String, bool) updateSelectedGroupIds;
@override
State<ContactGroupShortcutRow> createState() =>
_ContactGroupShortcutRowState();
}
class _ContactGroupShortcutRowState extends State<ContactGroupShortcutRow> {
List<ContactGroup> _contactGroups = [];
late final StreamSubscription<List<ContactGroup>> _subscription;
@override
void initState() {
super.initState();
_subscription = twonlyDB.contactGroupsDao
.watchShortcutContactGroups()
.listen((contactGroups) {
if (mounted) setState(() => _contactGroups = contactGroups);
});
}
@override
void dispose() {
unawaited(_subscription.cancel());
super.dispose();
}
Future<void> _openSettings([ContactGroup? contactGroup]) async {
await context.navPush(
ContactGroupSettingsView(
contactGroup: contactGroup,
initialShowAsShortcut: contactGroup == null,
),
);
}
Future<void> _apply(ContactGroup contactGroup) async {
await twonlyDB.contactGroupsDao.incrementUsage(contactGroup.id);
final members = await twonlyDB.contactGroupsDao.getMembers(contactGroup.id);
final targetGroupIds = <String>{};
for (final member in members) {
if (member.groupId != null) {
targetGroupIds.add(member.groupId!);
} else if (member.userId != null) {
final directChat = await twonlyDB.groupsDao.getDirectChat(
member.userId!,
);
if (directChat != null) targetGroupIds.add(directChat.groupId);
}
}
for (final groupId in widget.selectedGroupIds.toList()) {
widget.updateSelectedGroupIds(groupId, false);
}
for (final groupId in targetGroupIds) {
widget.updateSelectedGroupIds(groupId, true);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
ActionChip(
padding: EdgeInsets.zero,
tooltip: context.lang.createContactGroup,
onPressed: _openSettings,
label: _contactGroups.isEmpty
? Text(
context.lang.createContactGroup,
style: const TextStyle(fontSize: 9),
)
: const Icon(Icons.add_reaction_outlined, size: 20),
shape: const StadiumBorder(),
),
for (final contactGroup in _contactGroups)
GestureDetector(
onLongPress: () => _openSettings(contactGroup),
child: ActionChip(
padding: EdgeInsets.zero,
tooltip: contactGroup.name,
onPressed: () => _apply(contactGroup),
label: Text(
contactGroup.emoji ?? contactGroup.name,
style: const TextStyle(fontSize: 18),
),
shape: const StadiumBorder(),
),
),
],
),
);
}
}

View file

@ -1,115 +0,0 @@
import 'dart:async';
import 'dart:collection';
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';
import 'package:twonly/src/visual/views/camera/add_new_shortcut.view.dart';
class ShortcutRowComp extends StatefulWidget {
const ShortcutRowComp({
required this.selectedGroupIds,
required this.updateSelectedGroupIds,
super.key,
});
final HashSet<String> selectedGroupIds;
final void Function(String, bool) updateSelectedGroupIds;
@override
State<ShortcutRowComp> createState() => _ShortcutRowCompState();
}
class _ShortcutRowCompState extends State<ShortcutRowComp> {
List<Shortcut> _shortcuts = [];
late StreamSubscription<List<Shortcut>> shortcutSub;
@override
void initState() {
super.initState();
unawaited(initAsync());
}
Future<void> initAsync() async {
shortcutSub = twonlyDB.shortcutsDao.watchAllShortcuts().listen((shortcuts) {
if (_shortcuts.isEmpty) {
shortcuts.sort((a, b) => b.usageCounter.compareTo(a.usageCounter));
_shortcuts = shortcuts;
} else {
final map = {for (final s in shortcuts) s.id: s};
final updated = <Shortcut>[];
for (final old in _shortcuts) {
if (map.containsKey(old.id)) {
updated.add(map.remove(old.id)!);
}
}
updated.addAll(map.values);
_shortcuts = updated;
}
if (mounted) setState(() {});
});
}
@override
void dispose() {
unawaited(shortcutSub.cancel());
super.dispose();
}
Future<void> _openCreateDialog() async {
await context.navPush(const AddNewShortcutView());
}
Future<void> _applyShortcut(Shortcut shortcut) async {
await twonlyDB.shortcutsDao.incrementUsage(shortcut.id);
final members = await twonlyDB.shortcutsDao.getShortcutMembers(shortcut.id);
for (final groupId in widget.selectedGroupIds.toList()) {
widget.updateSelectedGroupIds(groupId, false);
}
for (final m in members) {
widget.updateSelectedGroupIds(m.groupId, true);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Row(
children: [
ActionChip(
padding: EdgeInsets.zero,
onPressed: _openCreateDialog,
label: _shortcuts.isEmpty
? Text(
context.lang.createShortcut,
style: const TextStyle(fontSize: 9),
)
: const Icon(Icons.add_reaction_outlined, size: 20),
shape: const StadiumBorder(),
),
for (final shortcut in _shortcuts)
GestureDetector(
onLongPress: () {
context.navPush(AddNewShortcutView(shortcut: shortcut));
},
child: ActionChip(
padding: EdgeInsets.zero,
onPressed: () => _applyShortcut(shortcut),
label: Text(
shortcut.emoji,
style: const TextStyle(fontSize: 18),
),
shape: const StadiumBorder(),
),
),
],
),
],
),
);
}
}

View file

@ -433,8 +433,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
}
},
onTap: _onCanvasTap,
child: MediaViewSizingHelper(
requiredHeight: 59,
child: MediaViewSizingHelper.cameraEditor(
bottomNavigation: EditorBottomBar(
mediaService: mediaService,
sendToGroup: widget.sendToGroup,

View file

@ -44,7 +44,8 @@ class _ChatListViewState extends State<ChatListView>
StreamSubscription<List<Message>>? _latestMessagesSub;
StreamSubscription<List<MediaFile>>? _chatListMediaSub;
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
StreamSubscription<List<(int, Label)>>? _contactLabelsSub;
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
Timer? _typingUpdateTimer;
final Set<String> _precachedMediaIds = {};
List<Group> _groupsNotPinned = [];
@ -58,7 +59,8 @@ class _ChatListViewState extends State<ChatListView>
Map<String, Message> _lastMessageByGroup = {};
Map<String, MediaFile> _chatListMediaById = {};
Map<String, VerificationStatus> _verificationByGroup = {};
Map<int, List<Label>> _labelsByContact = {};
Map<int, List<ContactGroup>> _contactGroupsByUser = {};
Map<String, List<ContactGroup>> _contactGroupsByGroup = {};
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
bool _loading = true;
@ -207,16 +209,39 @@ class _ChatListViewState extends State<ChatListView>
if (!mounted) return;
setState(() => _verificationByGroup = statuses);
});
_contactLabelsSub = twonlyDB.labelsDao.watchAllContactLabels().listen((
_contactGroupsSub = twonlyDB.contactGroupsDao
.watchAllVisibleUserGroups()
.listen((
rows,
) {
if (!mounted) return;
final labels = <int, List<Label>>{};
final contactGroups = <int, List<ContactGroup>>{};
for (final row in rows) {
labels.putIfAbsent(row.$1, () => []).add(row.$2);
contactGroups.putIfAbsent(row.$1, () => []).add(row.$2);
}
setState(() => _labelsByContact = labels);
setState(() => _contactGroupsByUser = contactGroups);
});
_chatContactGroupsSub = twonlyDB.contactGroupsDao
.watchAllVisibleChatGroups()
.listen((rows) {
if (!mounted) return;
final contactGroups = <String, List<ContactGroup>>{};
for (final row in rows) {
contactGroups.putIfAbsent(row.$1, () => []).add(row.$2);
}
setState(() => _contactGroupsByGroup = contactGroups);
});
}
/// Labels of a chat: those of the contact for direct chats, those of the
/// group itself otherwise.
List<ContactGroup> _contactGroupsFor(Group group) {
if (!group.isDirectChat) {
return _contactGroupsByGroup[group.groupId] ?? const [];
}
final contacts = _contactsByGroup[group.groupId];
if (contacts == null || contacts.isEmpty) return const [];
return _contactGroupsByUser[contacts.first.userId] ?? const [];
}
@override
@ -238,7 +263,8 @@ class _ChatListViewState extends State<ChatListView>
_latestMessagesSub?.cancel();
_chatListMediaSub?.cancel();
_verificationSub?.cancel();
_contactLabelsSub?.cancel();
_contactGroupsSub?.cancel();
_chatContactGroupsSub?.cancel();
super.dispose();
}
@ -419,13 +445,7 @@ class _ChatListViewState extends State<ChatListView>
mediaFiles: _mediaForGroup(group.groupId),
useSharedSummary: true,
verificationStatus: _verificationByGroup[group.groupId],
contactLabels:
_contactsByGroup[group.groupId]?.isNotEmpty == true
? _labelsByContact[_contactsByGroup[group.groupId]!
.first
.userId] ??
const []
: const [],
contactGroups: _contactGroupsFor(group),
);
}
@ -454,13 +474,7 @@ class _ChatListViewState extends State<ChatListView>
mediaFiles: _mediaForGroup(group.groupId),
useSharedSummary: true,
verificationStatus: _verificationByGroup[group.groupId],
contactLabels:
_contactsByGroup[group.groupId]?.isNotEmpty == true
? _labelsByContact[_contactsByGroup[group.groupId]!
.first
.userId] ??
const []
: const [],
contactGroups: _contactGroupsFor(group),
);
},
),

View file

@ -13,7 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/media_download_policy.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/contact_groups.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/group.context_menu.dart';
@ -32,7 +32,7 @@ class GroupListItemComp extends StatefulWidget {
this.mediaFiles,
this.useSharedSummary = false,
this.verificationStatus,
this.contactLabels = const [],
this.contactGroups = const [],
super.key,
});
final Group group;
@ -44,7 +44,7 @@ class GroupListItemComp extends StatefulWidget {
final Map<String, MediaFile>? mediaFiles;
final bool useSharedSummary;
final VerificationStatus? verificationStatus;
final List<Label> contactLabels;
final List<ContactGroup> contactGroups;
@override
State<GroupListItemComp> createState() => _UserListItem();
@ -324,9 +324,19 @@ class _UserListItem extends State<GroupListItemComp> {
return GroupContextMenu(
group: widget.group,
child: ListTile(
title: Row(
title: LayoutBuilder(
builder: (context, constraints) {
// Without labels the name may use the whole row.
final showBadges = widget.contactGroups.isNotEmpty;
// The name is capped instead of flexible so the badges get all
// of the space it does not use, rather than only half the row.
final nameMaxWidth = showBadges
? constraints.maxWidth * 0.6
: constraints.maxWidth;
return Row(
children: [
Flexible(
ConstrainedBox(
constraints: BoxConstraints(maxWidth: nameMaxWidth),
child: Text(
widget.group.groupName,
overflow: TextOverflow.ellipsis,
@ -342,20 +352,21 @@ class _UserListItem extends State<GroupListItemComp> {
clickable: false,
size: 12,
),
if (widget.group.isDirectChat && _directContact != null) ...[
if (showBadges) ...[
const SizedBox(width: 6),
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: ContactLabels(
contactId: _directContact!.userId,
labels: widget.contactLabels,
),
Expanded(
child: ContactGroupBadges(
userId: _directContact?.userId,
groupId: widget.group.isDirectChat
? null
: widget.group.groupId,
contactGroups: widget.contactGroups,
),
),
],
],
);
},
),
subtitle: _receiverDeletedAccount
? Text(context.lang.userDeletedAccount)

View file

@ -18,7 +18,7 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.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/contact_groups.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/themes/colors.dart';
@ -626,9 +626,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
FlameCounterWidget(group: group),
],
),
if (group.isDirectChat && _groupContacts.isNotEmpty)
ContactLabels(
contactId: _groupContacts.first.userId,
if (!group.isDirectChat)
ContactGroupBadges(groupId: group.groupId)
else if (_groupContacts.isNotEmpty)
ContactGroupBadges(
userId: _groupContacts.first.userId,
),
],
),

View file

@ -662,7 +662,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
});
},
child: Scaffold(
resizeToAvoidBottomInset: false,
body: SafeArea(
maintainBottomViewPadding: true,
child: Stack(
fit: StackFit.expand,
children: [
@ -738,6 +740,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (showSendTextMessageInput)
MediaViewerMessageInput(
controller: textMessageController,
safeAreaBottomPadding: MediaQuery.viewPaddingOf(
context,
).bottom,
onSubmitted: (value) => _sendTextMessage(),
onSendPressed: _sendTextMessage,
),

View file

@ -7,19 +7,26 @@ import 'package:twonly/src/visual/elements/my_input.element.dart';
class MediaViewerMessageInput extends StatelessWidget {
const MediaViewerMessageInput({
required this.controller,
required this.safeAreaBottomPadding,
required this.onSubmitted,
required this.onSendPressed,
super.key,
});
final TextEditingController controller;
final double safeAreaBottomPadding;
final ValueChanged<String> onSubmitted;
final VoidCallback onSendPressed;
@override
Widget build(BuildContext context) {
final keyboardBottomInset = MediaQuery.viewInsetsOf(context).bottom;
final bottomOffset = keyboardBottomInset > safeAreaBottomPadding
? keyboardBottomInset - safeAreaBottomPadding
: 0.0;
return Positioned(
bottom: 0,
bottom: bottomOffset,
left: 0,
right: 0,
child: Container(

View file

@ -12,7 +12,7 @@ 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/contact_groups.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/group.context_menu.dart';
@ -221,8 +221,8 @@ class _StartNewChatView extends State<StartNewChatView> {
return UserContextMenu(
key: ValueKey(contact.userId),
contact: contact,
child: ContactLabelsSubtitleBuilder(
contactId: contact.userId,
child: ContactGroupsSubtitleBuilder(
userId: contact.userId,
builder: (context, subtitleWidget) {
return ListTile(
title: Row(

View file

@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.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/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/contact_groups.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/snackbar.dart';
@ -22,7 +22,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/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/select_contact_labels.view.dart';
import 'package:twonly/src/visual/views/contact/select_contact_groups.view.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart';
class ContactView extends StatefulWidget {
@ -230,16 +230,16 @@ class _ContactViewState extends State<ContactView> {
userService.currentUser.userId,
),
),
ContactLabelsSubtitleBuilder(
contactId: contact.userId,
ContactGroupsSubtitleBuilder(
userId: contact.userId,
builder: (context, subtitleWidget) {
return BetterListTile(
icon: FontAwesomeIcons.tag,
text: context.lang.contactLabelsTitle,
text: context.lang.contactGroupsTitle,
subtitle: subtitleWidget,
onTap: () {
context.navPush(
SelectContactLabelsView(contactId: contact.userId),
SelectContactGroupsView(userId: contact.userId),
);
},
);

View file

@ -0,0 +1,571 @@
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_groups.comp.dart';
import 'package:twonly/src/visual/components/custom_color_picker_dialog.comp.dart';
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
class ContactGroupSettingsView extends StatefulWidget {
const ContactGroupSettingsView({
this.contactGroup,
this.initialShowAsShortcut = false,
super.key,
});
final ContactGroup? contactGroup;
final bool initialShowAsShortcut;
@override
State<ContactGroupSettingsView> createState() =>
_ContactGroupSettingsViewState();
}
class _MemberEntry {
const _MemberEntry({required this.name, this.contact, this.group});
final String name;
final Contact? contact;
final Group? group;
}
class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
static const _backgroundColors = [
0xFFE57373,
0xFFF06292,
0xFFBA68C8,
0xFF7986CB,
0xFF64B5F6,
0xFF4DD0E1,
0xFF4DB6AC,
0xFF81C784,
0xFFFFB74D,
0xFFFF8A65,
0xFF90A4AE,
0xFF424242,
];
static const _textColors = [
0xFFFFFFFF,
0xFF121212,
0xFF1B263B,
0xFF8B0000,
0xFF004D40,
0xFF4A148C,
];
late final TextEditingController _nameController;
late int _backgroundColor;
late int _textColor;
late bool _showAsShortcut;
late bool _showAsLabel;
String? _emoji;
bool _saving = false;
String _memberFilter = '';
List<Contact> _contacts = [];
List<Group> _groups = [];
Map<String, List<String>> _groupMemberNames = {};
final Set<int> _selectedUserIds = {};
final Set<String> _selectedGroupIds = {};
final List<StreamSubscription<dynamic>> _subscriptions = [];
bool get _isEditing => widget.contactGroup != null;
@override
void initState() {
super.initState();
final contactGroup = widget.contactGroup;
_nameController = TextEditingController(text: contactGroup?.name ?? '');
_backgroundColor = contactGroup?.backgroundColor ?? _backgroundColors[4];
_textColor = contactGroup?.textColor ?? _textColors[0];
_showAsShortcut =
contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut;
_showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut;
_emoji = contactGroup?.emoji;
_subscriptions.add(
twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) {
if (!mounted) return;
setState(() => _contacts = contacts);
}),
);
_subscriptions.add(
twonlyDB.groupsDao.watchGroupsForChatList().listen((groups) {
if (!mounted) return;
final actualGroups = groups
.where((group) => !group.isDirectChat && !group.deletedContent)
.toList();
setState(() => _groups = actualGroups);
}),
);
_subscriptions.add(
twonlyDB.groupsDao.watchAllGroupMembers().listen((members) {
if (!mounted) return;
final names = <String, List<String>>{};
for (final (contact, member) in members) {
names
.putIfAbsent(member.groupId, () => [])
.add(getContactDisplayName(contact));
}
for (final entry in names.entries) {
entry.value.sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
}
setState(() => _groupMemberNames = names);
}),
);
if (contactGroup != null) {
unawaited(_loadMembers(contactGroup.id));
}
}
Future<void> _loadMembers(int contactGroupId) async {
final members = await twonlyDB.contactGroupsDao.getMembers(contactGroupId);
if (!mounted) return;
setState(() {
_selectedUserIds
..clear()
..addAll(members.where((m) => m.userId != null).map((m) => m.userId!));
_selectedGroupIds
..clear()
..addAll(
members.where((m) => m.groupId != null).map((m) => m.groupId!),
);
});
}
@override
void dispose() {
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_nameController.dispose();
super.dispose();
}
List<_MemberEntry> get _members {
final entries = <_MemberEntry>[
for (final contact in _contacts)
_MemberEntry(name: getContactDisplayName(contact), contact: contact),
for (final group in _groups)
_MemberEntry(name: group.groupName, group: group),
]..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final filter = _memberFilter.trim().toLowerCase();
if (filter.isEmpty) return entries;
return entries
.where((entry) => entry.name.toLowerCase().contains(filter))
.toList();
}
Future<void> _selectEmoji() async {
final result = await showModalBottomSheet<dynamic>(
context: context,
backgroundColor: Colors.black,
builder: (context) => const EmojiPickerBottom(),
);
if (result is EmojiLayerData && mounted) {
setState(() => _emoji = result.text);
}
}
Future<void> _pickCustomColor({required bool background}) async {
final selected = await showDialog<int>(
context: context,
builder: (context) => CustomColorPickerDialog(
initialColor: Color(background ? _backgroundColor : _textColor),
),
);
if (selected == null || !mounted) return;
setState(() {
if (background) {
_backgroundColor = selected;
} else {
_textColor = selected;
}
});
}
Future<void> _save() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
if (_showAsShortcut && _emoji == null) {
showSnackbar(context, context.lang.contactGroupShortcutNeedsEmoji);
return;
}
setState(() => _saving = true);
try {
final id =
widget.contactGroup?.id ??
await twonlyDB.contactGroupsDao.createContactGroup(
name: name,
emoji: _emoji,
textColor: _textColor,
backgroundColor: _backgroundColor,
showAsShortcut: _showAsShortcut,
showAsLabel: _showAsLabel,
);
if (_isEditing) {
await twonlyDB.contactGroupsDao.updateContactGroup(
id: id,
name: name,
emoji: _emoji,
textColor: _textColor,
backgroundColor: _backgroundColor,
showAsShortcut: _showAsShortcut,
showAsLabel: _showAsLabel,
);
}
await twonlyDB.contactGroupsDao.replaceMembers(
id,
userIds: _selectedUserIds,
groupIds: _selectedGroupIds,
);
if (mounted) Navigator.pop(context, id);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _delete() async {
final contactGroup = widget.contactGroup;
if (contactGroup == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.lang.deleteContactGroup),
content: Text(context.lang.deleteContactGroupConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.delete),
),
],
),
);
if (confirmed != true) return;
await twonlyDB.contactGroupsDao.deleteContactGroup(contactGroup.id);
if (mounted) Navigator.pop(context);
}
Widget _colorButton(
int color,
bool selected,
VoidCallback onTap, {
String? tooltip,
}) {
final button = GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color == noContactGroupBackgroundColor ? null : Color(color),
shape: BoxShape.circle,
border: Border.all(
color: selected
? (isDarkMode(context) ? Colors.white : Colors.black)
: Theme.of(context).colorScheme.outline,
width: selected ? 3 : 1,
),
),
child: color == noContactGroupBackgroundColor
? const Icon(Icons.format_color_reset, size: 18)
: selected
? const Icon(Icons.check, size: 18)
: null,
),
);
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
}
Widget _customColorButton({required bool background}) {
return Tooltip(
message: context.lang.customColor,
child: GestureDetector(
onTap: () => _pickCustomColor(background: background),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).colorScheme.outline),
),
child: const Icon(Icons.colorize, size: 18),
),
),
);
}
Widget _nameEditor() {
final hasBackground = contactGroupHasBackground(_backgroundColor);
final fontSize = contactGroupFontSize(13, _backgroundColor);
final style = TextStyle(
color: Color(_textColor),
fontSize: fontSize,
fontWeight: FontWeight.w600,
);
// The field has no intrinsic width, so measure the text to let the badge
// hug its content just like the rendered label does.
final painter = TextPainter(
text: TextSpan(text: _nameController.text, style: style),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout();
return Center(
child: Container(
padding: hasBackground
? const EdgeInsets.symmetric(horizontal: 10, vertical: 4)
: EdgeInsets.zero,
decoration: hasBackground
? BoxDecoration(
color: Color(_backgroundColor),
borderRadius: BorderRadius.circular(12),
)
: null,
child: SizedBox(
// Extra space keeps the caret visible behind the last character.
width: painter.width.clamp(16, 240) + 8,
child: TextField(
controller: _nameController,
autofocus: true,
maxLength: 24,
textAlign: TextAlign.center,
textCapitalization: TextCapitalization.words,
style: style,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
counterText: '',
),
onChanged: (_) => setState(() {}),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final hasBackground = contactGroupHasBackground(_backgroundColor);
final members = _members;
return Scaffold(
appBar: AppBar(
title: Text(
_isEditing
? context.lang.editContactGroup
: context.lang.createContactGroup,
),
actions: [
if (_isEditing)
IconButton(
tooltip: context.lang.deleteContactGroup,
onPressed: _delete,
icon: const FaIcon(
FontAwesomeIcons.trashCan,
size: 18,
color: Colors.red,
),
),
],
),
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
floatingActionButton: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: (_saving || _nameController.text.trim().isEmpty)
? null
: _save,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_saving)
const SizedBox.square(
dimension: 15,
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
)
else
const FaIcon(FontAwesomeIcons.check, size: 16),
const SizedBox(width: 8),
Text(context.lang.save),
],
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 100),
children: [
_nameEditor(),
const SizedBox(height: 20),
Text(
context.lang.contactGroupBackgroundColor,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_colorButton(
noContactGroupBackgroundColor,
!hasBackground,
() => setState(
() => _backgroundColor = noContactGroupBackgroundColor,
),
tooltip: context.lang.contactGroupNoBackground,
),
for (final color in _backgroundColors)
_colorButton(
color,
_backgroundColor == color,
() => setState(() => _backgroundColor = color),
),
_customColorButton(background: true),
],
),
const SizedBox(height: 20),
Text(
context.lang.contactGroupTextColor,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final color in _textColors)
_colorButton(
color,
_textColor == color,
() => setState(() => _textColor = color),
),
_customColorButton(background: false),
],
),
const Divider(height: 40),
Text(
context.lang.contactGroupFeatures,
style: Theme.of(context).textTheme.titleMedium,
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(context.lang.contactGroupShowAsLabel),
subtitle: Text(context.lang.contactGroupShowAsLabelSubtitle),
value: _showAsLabel,
onChanged: (value) => setState(() => _showAsLabel = value),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(context.lang.contactGroupShowAsShortcut),
subtitle: Text(context.lang.contactGroupShowAsShortcutSubtitle),
value: _showAsShortcut,
onChanged: (value) => setState(() => _showAsShortcut = value),
),
if (_showAsShortcut)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.lang.selectEmoji),
trailing: Text(
_emoji ?? '+',
style: const TextStyle(fontSize: 24),
),
onTap: _selectEmoji,
),
const Divider(height: 40),
Text(
context.lang.contactGroupMembers,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
onChanged: (value) => setState(() => _memberFilter = value),
decoration: getInputDecoration(
context,
context.lang.shareImageSearchAllContacts,
),
),
for (final entry in members) _memberTile(entry),
],
),
);
}
void _toggleEntry(_MemberEntry entry) {
setState(() {
final contact = entry.contact;
if (contact != null) {
if (!_selectedUserIds.add(contact.userId)) {
_selectedUserIds.remove(contact.userId);
}
} else if (!_selectedGroupIds.add(entry.group!.groupId)) {
_selectedGroupIds.remove(entry.group!.groupId);
}
});
}
Widget _memberTile(_MemberEntry entry) {
final contact = entry.contact;
final group = entry.group;
final selected = contact != null
? _selectedUserIds.contains(contact.userId)
: _selectedGroupIds.contains(group!.groupId);
final memberNames = group == null
? const <String>[]
: _groupMemberNames[group.groupId] ?? const <String>[];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: contact != null
? AvatarIcon(contactId: contact.userId, fontSize: 14)
: AvatarIcon(group: group, fontSize: 14),
title: Row(
children: [
Flexible(
child: Text(
entry.name,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: contact,
group: group,
showOnlyIfVerified: true,
clickable: false,
size: 12,
),
],
),
subtitle: memberNames.isEmpty
? null
: Text(
memberNames.join(', '),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).disabledColor,
),
),
trailing: Checkbox.adaptive(
value: selected,
onChanged: (_) => _toggleEntry(entry),
),
onTap: () => _toggleEntry(entry),
);
}
}

View file

@ -0,0 +1,253 @@
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_groups.comp.dart';
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
class SelectContactGroupsView extends StatefulWidget {
const SelectContactGroupsView({this.userId, this.groupId, super.key})
: assert(
userId != null || groupId != null,
'pass either a userId or a groupId',
);
final int? userId;
final String? groupId;
@override
State<SelectContactGroupsView> createState() =>
_SelectContactGroupsViewState();
}
class _SelectContactGroupsViewState extends State<SelectContactGroupsView> {
Contact? _contact;
Group? _group;
List<ContactGroup> _contactGroups = [];
Set<int> _selectedIds = {};
final List<StreamSubscription<dynamic>> _subscriptions = [];
String? get _groupId => widget.groupId;
@override
void initState() {
super.initState();
final groupId = _groupId;
if (groupId != null) {
_subscriptions.add(
twonlyDB.groupsDao.watchGroup(groupId).listen((group) {
if (mounted) setState(() => _group = group);
}),
);
} else {
_subscriptions.add(
twonlyDB.contactsDao.watchContact(widget.userId!).listen((contact) {
if (mounted) setState(() => _contact = contact);
}),
);
}
_subscriptions.add(
twonlyDB.contactGroupsDao.watchAllContactGroups().listen((groups) {
if (mounted) setState(() => _contactGroups = groups);
}),
);
_subscriptions.add(
(groupId != null
? twonlyDB.contactGroupsDao.watchContactGroupIdsForGroup(groupId)
: twonlyDB.contactGroupsDao.watchContactGroupIdsForUser(
widget.userId!,
))
.listen((ids) {
if (mounted) setState(() => _selectedIds = ids);
}),
);
}
@override
void dispose() {
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
super.dispose();
}
Future<void> _toggle(ContactGroup contactGroup) async {
final selected = _selectedIds.contains(contactGroup.id);
setState(() {
if (selected) {
_selectedIds.remove(contactGroup.id);
} else {
_selectedIds.add(contactGroup.id);
}
});
final groupId = _groupId;
if (groupId != null) {
await twonlyDB.contactGroupsDao.setGroupMembership(
contactGroup.id,
groupId,
!selected,
);
} else {
await twonlyDB.contactGroupsDao.setUserMembership(
contactGroup.id,
widget.userId!,
!selected,
);
}
}
Future<void> _openSettings([ContactGroup? contactGroup]) async {
await context.navPush(
ContactGroupSettingsView(contactGroup: contactGroup),
);
}
Widget _preview(ContactGroup contactGroup) {
final hasBackground = contactGroupHasBackground(
contactGroup.backgroundColor,
);
return Container(
padding: hasBackground
? const EdgeInsets.symmetric(horizontal: 8, vertical: 3)
: EdgeInsets.zero,
decoration: hasBackground
? BoxDecoration(
color: Color(contactGroup.backgroundColor),
borderRadius: BorderRadius.circular(12),
)
: null,
child: Text(
contactGroup.name,
style: TextStyle(
color: Color(contactGroup.textColor),
fontSize: contactGroupFontSize(11, contactGroup.backgroundColor),
fontWeight: FontWeight.w600,
),
),
);
}
Widget _header() {
final group = _group;
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: [
if (group != null)
AvatarIcon(group: group, fontSize: 24)
else
AvatarIcon(contactId: widget.userId, fontSize: 24),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group != null
? group.groupName
: getContactDisplayName(_contact!, maxLength: 25),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
ContactGroupBadges(
userId: widget.userId,
groupId: widget.groupId,
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.contactGroupsTitle),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.lang.createContactGroup,
onPressed: _openSettings,
),
],
),
body: Column(
children: [
if (_contact != null || _group != null) _header(),
Expanded(
child: _contactGroups.isEmpty
? Center(
child: FilledButton.icon(
onPressed: _openSettings,
icon: const Icon(Icons.add),
label: Text(context.lang.createContactGroup),
),
)
: ListView.builder(
itemCount: _contactGroups.length,
itemBuilder: (context, index) {
final contactGroup = _contactGroups[index];
final selected = _selectedIds.contains(contactGroup.id);
final features = [
if (contactGroup.showAsLabel)
context.lang.contactGroupLabelFeature,
if (contactGroup.showAsShortcut)
context.lang.contactGroupShortcutFeature,
].join(' · ');
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
visualDensity: VisualDensity.compact,
horizontalTitleGap: 4,
leading: Checkbox.adaptive(
value: selected,
onChanged: (_) => _toggle(contactGroup),
),
title: Align(
alignment: Alignment.centerLeft,
child: _preview(contactGroup),
),
subtitle: features.isEmpty
? null
: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
features,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).disabledColor,
),
),
),
trailing: IconButton(
tooltip: context.lang.contactGroupSettings,
onPressed: () => _openSettings(contactGroup),
icon: const FaIcon(FontAwesomeIcons.gear, size: 17),
),
onTap: () => _toggle(contactGroup),
);
},
),
),
],
),
);
}
}

View file

@ -1,334 +0,0 @@
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

@ -12,13 +12,14 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.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/contact_labels.comp.dart';
import 'package:twonly/src/visual/components/contact_groups.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/snackbar.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/views/contact/contact.view.dart';
import 'package:twonly/src/visual/views/contact/select_contact_groups.view.dart';
import 'package:twonly/src/visual/views/groups/group_create_select_members.view.dart';
import 'package:twonly/src/visual/views/groups/group_member.context.dart';
import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
@ -212,6 +213,21 @@ class _GroupViewState extends State<GroupView> {
groupId: widget.groupId,
disabled: !_group!.isGroupAdmin,
),
ContactGroupsSubtitleBuilder(
groupId: widget.groupId,
builder: (context, subtitleWidget) {
return BetterListTile(
icon: FontAwesomeIcons.tag,
text: context.lang.contactGroupsTitle,
subtitle: subtitleWidget,
onTap: () {
context.navPush(
SelectContactGroupsView(groupId: widget.groupId),
);
},
);
},
),
const Divider(),
ListTile(
title: Padding(
@ -254,8 +270,8 @@ class _GroupViewState extends State<GroupView> {
group: _group!,
contact: member.$1,
member: member.$2,
child: ContactLabelsSubtitleBuilder(
contactId: member.$1.userId,
child: ContactGroupsSubtitleBuilder(
userId: member.$1.userId,
builder: (context, subtitleWidget) {
return BetterListTile(
padding: const EdgeInsets.only(left: 13),

View file

@ -7,7 +7,7 @@ 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/contact_groups.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';
@ -126,8 +126,8 @@ class _GroupCreateSelectGroupNameViewState
return UserContextMenu(
key: ValueKey(user.userId),
contact: user,
child: ContactLabelsSubtitleBuilder(
contactId: user.userId,
child: ContactGroupsSubtitleBuilder(
userId: user.userId,
builder: (context, subtitleWidget) {
return ListTile(
title: Row(

View file

@ -10,7 +10,7 @@ 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/contact_groups.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/verification_badge.comp.dart';
@ -219,8 +219,8 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
return UserContextMenu(
key: ValueKey(user.userId),
contact: user,
child: ContactLabelsSubtitleBuilder(
contactId: user.userId,
child: ContactGroupsSubtitleBuilder(
userId: user.userId,
additionalSubtitle:
alreadyInGroup.contains(user.userId)
? Text(context.lang.alreadyInGroup)

View file

@ -15,6 +15,7 @@ import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/settings/backup/backup_utils.dart';
import 'package:twonly/src/visual/views/settings/backup/components/recovery_card.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/memories_backup_detail.view.dart';
@ -185,7 +186,10 @@ class _BackupViewState extends State<BackupView> {
_backupStatus?.archiveSize,
),
),
trailing: const Icon(Icons.check_circle, color: Colors.green),
trailing: const Icon(
Icons.check_circle,
color: defaultPrimaryColor,
),
),
// Bilder & Medien

View file

@ -492,7 +492,24 @@ mod tests {
crate::database::app::AppDatabase::new(&legacy_path.display().to_string(), None, false)
.await
.unwrap();
legacy.run_migrations().await.unwrap();
for migration in [
include_str!("../database/app/migrations/0001_initial.sql"),
include_str!("../database/app/migrations/0002_api_outbox.sql"),
include_str!("../database/app/migrations/0003_notification_outbox.sql"),
include_str!("../database/app/migrations/0004_sealed_sender.sql"),
include_str!("../database/app/migrations/0005_direct_media_upload.sql"),
include_str!("../database/app/migrations/0006_defer_receipts_missing_bundle.sql"),
include_str!("../database/app/migrations/0007_remove_experimental_transport.sql"),
include_str!("../database/app/migrations/0008_pending_plaintext.sql"),
include_str!("../database/app/migrations/0009_outbox_dispatch.sql"),
include_str!("../database/app/migrations/0010_media_trim.sql"),
include_str!("../database/app/migrations/0011_outgoing_contact_request.sql"),
] {
sqlx::raw_sql(migration)
.execute(&legacy.pool)
.await
.unwrap();
}
sqlx::query!(r#"PRAGMA user_version = 25"#)
.execute(&legacy.pool)
.await

View file

@ -3,7 +3,9 @@
*
*/
use super::{AppDatabase, MigrationReport, TableMigrationCount, APPLICATION_TABLES};
use super::{
AppDatabase, MigrationReport, TableMigrationCount, APPLICATION_TABLES, LEGACY_COPY_TABLES,
};
use crate::error::{Result, TwonlyError};
use sqlx::{Acquire, AssertSqlSafe, Row};
use std::collections::BTreeSet;
@ -114,7 +116,7 @@ async fn import(
.execute(&mut *tx)
.await?;
let mut counts = Vec::with_capacity(APPLICATION_TABLES.len());
for table in APPLICATION_TABLES {
for table in LEGACY_COPY_TABLES {
let columns = common_columns(&mut tx, table).await?;
if columns.is_empty() {
return Err(TwonlyError::Generic(format!(
@ -166,6 +168,7 @@ async fn import(
rows: source_count,
});
}
migrate_legacy_contact_groups(&mut tx, &mut counts).await?;
copy_sequences(&mut tx).await?;
let foreign_key_errors =
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM pragma_foreign_key_check"#)
@ -202,6 +205,80 @@ async fn import(
})
}
async fn migrate_legacy_contact_groups(
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
counts: &mut Vec<TableMigrationCount>,
) -> Result<()> {
sqlx::query(
r#"INSERT INTO main.contact_groups (
id, name, emoji, text_color, background_color,
show_as_shortcut, show_as_label, usage_counter, created_at
)
SELECT id, name, NULL, text_color, background_color,
0, 1, 0, created_at
FROM legacy.labels"#,
)
.execute(&mut **connection)
.await?;
sqlx::query(
r#"INSERT OR IGNORE INTO main.contact_group_members
(contact_group_id, user_id, group_id)
SELECT label_id, contact_id, NULL
FROM legacy.contact_labels"#,
)
.execute(&mut **connection)
.await?;
sqlx::query(
r#"INSERT INTO main.contact_groups (
id, name, emoji, text_color, background_color,
show_as_shortcut, show_as_label, usage_counter, created_at
)
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + id,
emoji, emoji, 4278190080, 0,
1, 0, usage_counter,
CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)
FROM legacy.shortcuts"#,
)
.execute(&mut **connection)
.await?;
sqlx::query(
r#"INSERT OR IGNORE INTO main.contact_group_members
(contact_group_id, user_id, group_id)
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + sm.shortcut_id,
gm.contact_id, NULL
FROM legacy.shortcut_members sm
INNER JOIN legacy.groups g ON g.group_id = sm.group_id
INNER JOIN legacy.group_members gm ON gm.group_id = g.group_id
WHERE g.is_direct_chat = 1"#,
)
.execute(&mut **connection)
.await?;
sqlx::query(
r#"INSERT OR IGNORE INTO main.contact_group_members
(contact_group_id, user_id, group_id)
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + sm.shortcut_id,
NULL, sm.group_id
FROM legacy.shortcut_members sm
INNER JOIN legacy.groups g ON g.group_id = sm.group_id
WHERE g.is_direct_chat = 0"#,
)
.execute(&mut **connection)
.await?;
for table in ["contact_groups", "contact_group_members"] {
let rows = sqlx::query_scalar::<_, i64>(AssertSqlSafe(format!(
r#"SELECT COUNT(*) FROM main."{table}""#
)))
.fetch_one(&mut **connection)
.await?;
counts.push(TableMigrationCount {
table: table.to_owned(),
rows,
});
}
Ok(())
}
async fn common_columns(
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table: &str,
@ -239,8 +316,7 @@ async fn copy_sequences(connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>) ->
"verification_tokens",
"user_discovery_own_promotions",
"user_discovery_shares",
"shortcuts",
"labels",
"contact_groups",
] {
let source_sequence: Option<i64> =
sqlx::query_scalar(r#"SELECT seq FROM legacy.sqlite_sequence WHERE name = ?"#)
@ -279,7 +355,7 @@ mod tests {
let legacy = AppDatabase::new(legacy_path.to_str().unwrap(), None, false)
.await
.unwrap();
legacy.run_migrations().await.unwrap();
create_legacy_schema(&legacy.pool).await;
sqlx::query!(r#"PRAGMA user_version = 25"#)
.execute(&legacy.pool)
.await
@ -294,11 +370,51 @@ mod tests {
target.run_migrations().await.unwrap();
let first = target.import_legacy(&legacy_path).await.unwrap();
assert_eq!(first.tables.len(), APPLICATION_TABLES.len());
assert!(first.tables.iter().all(|entry| entry.rows == 1));
assert!(first
.tables
.iter()
.filter(|entry| !entry.table.starts_with("contact_group"))
.all(|entry| entry.rows == 1));
assert_eq!(
first
.tables
.iter()
.find(|entry| entry.table == "contact_groups")
.unwrap()
.rows,
2
);
assert_eq!(
first
.tables
.iter()
.find(|entry| entry.table == "contact_group_members")
.unwrap()
.rows,
2
);
let second = target.import_legacy(&legacy_path).await.unwrap();
assert_eq!(first, second);
assert!(target.is_legacy_import_complete().await.unwrap());
let migrated_shortcut = sqlx::query_as::<_, (String, String, i64, i64)>(
r#"SELECT name, emoji, background_color, show_as_shortcut
FROM contact_groups WHERE emoji = '🔥'"#,
)
.fetch_one(&target.pool)
.await
.unwrap();
assert_eq!(migrated_shortcut, ("🔥".into(), "🔥".into(), 0, 1));
let migrated_group_target: String = sqlx::query_scalar(
r#"SELECT group_id
FROM contact_group_members
WHERE group_id IS NOT NULL"#,
)
.fetch_one(&target.pool)
.await
.unwrap();
assert_eq!(migrated_group_target, "group-1");
let selected = target
.raw_select(
"SELECT username, avatar_svg_compressed FROM contacts WHERE user_id = ?".to_owned(),
@ -369,6 +485,87 @@ mod tests {
}
}
async fn create_legacy_schema(pool: &SqlitePool) {
for migration in [
include_str!("migrations/0001_initial.sql"),
include_str!("migrations/0002_api_outbox.sql"),
include_str!("migrations/0003_notification_outbox.sql"),
include_str!("migrations/0004_sealed_sender.sql"),
include_str!("migrations/0005_direct_media_upload.sql"),
include_str!("migrations/0006_defer_receipts_missing_bundle.sql"),
include_str!("migrations/0007_remove_experimental_transport.sql"),
include_str!("migrations/0008_pending_plaintext.sql"),
include_str!("migrations/0009_outbox_dispatch.sql"),
include_str!("migrations/0010_media_trim.sql"),
include_str!("migrations/0011_outgoing_contact_request.sql"),
] {
sqlx::raw_sql(migration).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn rust_migration_converts_direct_users_and_actual_groups() {
let directory = tempdir().unwrap();
let path = directory.path().join("pre-contact-groups.sqlite");
let database = AppDatabase::new(path.to_str().unwrap(), None, false)
.await
.unwrap();
create_legacy_schema(&database.pool).await;
for statement in [
"INSERT INTO contacts(user_id, username) VALUES(7, 'alice')",
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES('direct-1', 'Alice', 1)",
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES('group-1', 'Friends', 0)",
"INSERT INTO group_members(group_id, contact_id) VALUES('direct-1', 7)",
"INSERT INTO shortcuts(id, emoji, usage_counter) VALUES(3, '🔥', 5)",
"INSERT INTO shortcut_members(shortcut_id, group_id) VALUES(3, 'direct-1')",
"INSERT INTO shortcut_members(shortcut_id, group_id) VALUES(3, 'group-1')",
"INSERT INTO labels(id, name, text_color, background_color) VALUES(5, 'Family', 1, 2)",
"INSERT INTO contact_labels(contact_id, label_id) VALUES(7, 5)",
] {
sqlx::query(statement)
.execute(&database.pool)
.await
.unwrap();
}
sqlx::raw_sql(include_str!("migrations/0012_contact_groups.sql"))
.execute(&database.pool)
.await
.unwrap();
let shortcut = sqlx::query_as::<_, (i64, String, String, i64, i64, i64)>(
r#"SELECT id, name, emoji, background_color, show_as_shortcut, usage_counter
FROM contact_groups WHERE emoji = '🔥'"#,
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(shortcut, (8, "🔥".into(), "🔥".into(), 0, 1, 5));
let members = sqlx::query_as::<_, (Option<i64>, Option<String>)>(
r#"SELECT user_id, group_id
FROM contact_group_members
WHERE contact_group_id = 8
ORDER BY group_id"#,
)
.fetch_all(&database.pool)
.await
.unwrap();
assert_eq!(
members,
vec![(Some(7), None), (None, Some("group-1".into()))]
);
let old_table_count: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table'
AND name IN ('shortcuts', 'shortcut_members', 'labels', 'contact_labels')"#,
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(old_table_count, 0);
}
async fn populate_every_table(pool: &SqlitePool) {
let statements = [
"INSERT INTO contacts(user_id, username, avatar_svg_compressed, signal_version) VALUES(7, 'alice', x'0001FF', 'v2')",

View file

@ -0,0 +1,100 @@
CREATE TABLE contact_groups (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
emoji TEXT,
text_color INTEGER NOT NULL,
background_color INTEGER NOT NULL,
show_as_shortcut INTEGER NOT NULL DEFAULT 0 CHECK (show_as_shortcut IN (0, 1)),
show_as_label INTEGER NOT NULL DEFAULT 1 CHECK (show_as_label IN (0, 1)),
usage_counter INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE contact_group_members (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
contact_group_id INTEGER NOT NULL REFERENCES contact_groups(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES contacts(user_id) ON DELETE CASCADE,
group_id TEXT REFERENCES groups(group_id) ON DELETE CASCADE,
UNIQUE (contact_group_id, user_id),
UNIQUE (contact_group_id, group_id),
CHECK ((user_id IS NOT NULL) != (group_id IS NOT NULL))
);
-- Existing labels remain visible contact groups with their current styling.
INSERT INTO contact_groups (
id,
name,
emoji,
text_color,
background_color,
show_as_shortcut,
show_as_label,
usage_counter,
created_at
)
SELECT
id,
name,
NULL,
text_color,
background_color,
0,
1,
0,
created_at
FROM labels;
INSERT OR IGNORE INTO contact_group_members (contact_group_id, user_id, group_id)
SELECT label_id, contact_id, NULL
FROM contact_labels;
-- Existing shortcuts become shortcut-only contact groups. Their emoji is
-- retained and their label appearance has a transparent background.
INSERT INTO contact_groups (
id,
name,
emoji,
text_color,
background_color,
show_as_shortcut,
show_as_label,
usage_counter,
created_at
)
SELECT
COALESCE((SELECT MAX(id) FROM labels), 0) + id,
emoji,
emoji,
4278190080,
0,
1,
0,
usage_counter,
CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)
FROM shortcuts;
-- A shortcut's direct-chat target is represented by its contact user id.
INSERT OR IGNORE INTO contact_group_members (contact_group_id, user_id, group_id)
SELECT
COALESCE((SELECT MAX(id) FROM labels), 0) + sm.shortcut_id,
gm.contact_id,
NULL
FROM shortcut_members sm
INNER JOIN groups g ON g.group_id = sm.group_id
INNER JOIN group_members gm ON gm.group_id = g.group_id
WHERE g.is_direct_chat = 1;
-- Only actual, non-direct groups are represented by group id.
INSERT OR IGNORE INTO contact_group_members (contact_group_id, user_id, group_id)
SELECT
COALESCE((SELECT MAX(id) FROM labels), 0) + sm.shortcut_id,
NULL,
sm.group_id
FROM shortcut_members sm
INNER JOIN groups g ON g.group_id = sm.group_id
WHERE g.is_direct_chat = 0;
DROP TABLE contact_labels;
DROP TABLE shortcut_members;
DROP TABLE labels;
DROP TABLE shortcuts;

View file

@ -16,12 +16,10 @@ mod legacy_import;
pub mod tables;
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
pub const APP_SCHEMA_VERSION: i64 = 6;
pub const APP_SCHEMA_VERSION: i64 = 7;
/// Tables imported from the legacy Drift database. Every entry must exist in
/// Drift schema 25, because a missing table aborts the whole import. Rust-only
/// tables such as `notification_outbox` are deliberately absent: they have no
/// legacy counterpart, and importing stale rows would replay old notifications.
/// User-owned application tables in the current Rust schema. Rust-only outbox
/// tables are deliberately absent because they are reconstructed locally.
pub const APPLICATION_TABLES: &[&str] = &[
"contacts",
"groups",
@ -41,10 +39,31 @@ pub const APPLICATION_TABLES: &[&str] = &[
"user_discovery_other_promotions",
"user_discovery_own_promotions",
"user_discovery_shares",
"shortcuts",
"shortcut_members",
"labels",
"contact_labels",
"contact_groups",
"contact_group_members",
];
/// Tables copied one-to-one from the legacy Drift schema. Contact labels and
/// shortcuts need a typed transformation into the unified contact-group tables.
pub const LEGACY_COPY_TABLES: &[&str] = &[
"contacts",
"groups",
"media_files",
"messages",
"message_histories",
"reactions",
"group_members",
"receipts",
"received_receipts",
"message_actions",
"group_histories",
"key_verifications",
"verification_tokens",
"user_discovery_announced_users",
"user_discovery_user_relations",
"user_discovery_other_promotions",
"user_discovery_own_promotions",
"user_discovery_shares",
];
#[derive(Clone, Debug, PartialEq, Eq)]

View file

@ -6,7 +6,7 @@ use crate::api::proto::http_requests::{
};
use crate::bridge::api::RustApi;
use crate::context::Context;
use crate::database::app::tables::MediaFile;
use crate::database::app::{tables::MediaFile, AppDatabase};
use crate::error::{Result, TwonlyError};
use crate::native::transfer;
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
@ -34,10 +34,16 @@ const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
const DEFAULT_SLOT_REFILL_THRESHOLD: i64 = 5;
/// Where the server's advertised refill threshold is remembered between runs.
const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
/// Upload slots and attachment capabilities belong to exactly one API
/// deployment. Debug and profile builds share the `.testing` application data,
/// so this marker prevents a build switch from reusing the other server's
/// durable jobs.
const API_NAMESPACE_KEY: &str = "direct_media_api_namespace";
const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2);
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
static WATCHING: AtomicBool = AtomicBool::new(false);
static API_NAMESPACE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Bumped every time something asks to be watched. A watcher already in flight
/// reads this as "new work arrived" and drops back to the short poll interval.
@ -180,6 +186,68 @@ struct NativeUploadDescriptor {
complete: NativeRequest,
}
async fn reset_upload_namespace(
database: &AppDatabase,
data_dir: &Path,
namespace: &str,
) -> Result<(Option<String>, usize)> {
let stored = sqlx::query_scalar::<_, String>("SELECT value FROM app_metadata WHERE key = ?")
.bind(API_NAMESPACE_KEY)
.fetch_optional(&database.pool)
.await?;
if stored.as_deref() == Some(namespace) {
return Ok((stored, 0));
}
let stale_files = sqlx::query_as::<_, (String, String, String, String)>(
r#"SELECT attachment_id, multipart_path, manifest_path, complete_body_path
FROM direct_media_upload_jobs"#,
)
.fetch_all(&database.pool)
.await?;
let mut transaction = database.pool.begin().await?;
sqlx::query(
r#"UPDATE media_files
SET upload_state = 'preprocessing', pre_progressing_process = NULL
WHERE upload_state != 'uploaded'
AND media_id IN (SELECT media_id FROM direct_media_upload_jobs)"#,
)
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM direct_media_upload_jobs WHERE 1")
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM direct_media_upload_slots WHERE 1")
.execute(&mut *transaction)
.await?;
sqlx::query(
r#"INSERT INTO app_metadata(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value"#,
)
.bind(API_NAMESPACE_KEY)
.bind(namespace)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
for (attachment_id, multipart, manifest, complete) in &stale_files {
for path in [multipart, manifest, complete] {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
tracing::warn!(path, %error, "could not remove stale upload request file");
}
}
}
let job_dir = data_dir.join("direct-media-upload").join(attachment_id);
let _ = std::fs::remove_dir(job_dir);
}
Ok((stored, stale_files.len()))
}
#[derive(Clone, Copy)]
enum Outcome {
Uploaded,
@ -200,6 +268,34 @@ impl DirectMediaUploadService {
format!("{}{}", RustApi::api_base_url("https".into()), path)
}
/// Invalidates durable upload state created for another API deployment.
///
/// Attachment ids, capabilities, pre-signed object-store requests, and the
/// manifest/complete URLs are all server-specific. Keeping them when a
/// profile build (production API) is replaced by a debug build (development
/// API), or vice versa, leaves the media in `backgroundUploadTaskStarted`
/// until the old slot expires. Returning the media to `preprocessing` lets
/// the ordinary startup sweep reserve a fresh slot and really retry it.
async fn ensure_api_namespace(&self) -> Result<()> {
let _guard = API_NAMESPACE_LOCK.lock().await;
let namespace = RustApi::api_base_url("https".into());
let database = self.ctx.app_db.read().await.clone();
let (stored, restarted) =
reset_upload_namespace(&database, Path::new(&self.ctx.config.data_dir), &namespace)
.await?;
if stored.as_deref() == Some(namespace.as_str()) {
return Ok(());
}
tracing::info!(
previous = stored.as_deref().unwrap_or("unset"),
current = namespace,
restarted,
"reset direct-media uploads after API deployment changed"
);
Ok(())
}
async fn authenticated_request(
&self,
request: reqwest::RequestBuilder,
@ -230,6 +326,7 @@ impl DirectMediaUploadService {
}
pub async fn preload_slots(&self) -> Result<usize> {
self.ensure_api_namespace().await?;
let database = self.ctx.app_db.read().await.clone();
let known = sqlx::query_scalar::<_, String>(
"SELECT attachment_id FROM direct_media_upload_slots WHERE state IN ('cached', 'reserved') AND expires_at > CAST(strftime('%s','now') AS INTEGER)",
@ -332,6 +429,7 @@ impl DirectMediaUploadService {
}
async fn ensure_slots(&self) -> Result<()> {
self.ensure_api_namespace().await?;
let usable = self.usable_slot_count().await?;
if usable > self.refill_threshold().await {
return Ok(());
@ -391,6 +489,7 @@ impl DirectMediaUploadService {
/// while the manifest is still being reconciled, so only the attachment's
/// own state says whether the recipients were dispatched.
pub async fn reconcile(&self) -> Result<()> {
self.ensure_api_namespace().await?;
let database = self.ctx.app_db.read().await.clone();
let jobs = sqlx::query_as::<_, PendingJob>(
r#"SELECT j.attachment_id, j.media_id, j.multipart_path, j.manifest_path,
@ -873,6 +972,91 @@ mod tests {
assert!(!WATCHING.load(Ordering::SeqCst));
}
#[tokio::test]
async fn api_namespace_change_restarts_durable_uploads() {
let directory = tempfile::tempdir().unwrap();
let database_path = directory.path().join("app.sqlite");
let database = AppDatabase::new(database_path.to_str().unwrap(), None, false)
.await
.unwrap();
database.run_migrations().await.unwrap();
let job_dir = directory.path().join("direct-media-upload/attachment-1");
std::fs::create_dir_all(&job_dir).unwrap();
let multipart = job_dir.join("media.multipart");
let manifest = job_dir.join("manifest.pb");
let complete = job_dir.join("complete.pb");
for path in [&multipart, &manifest, &complete] {
std::fs::write(path, b"request").unwrap();
}
sqlx::query("INSERT INTO app_metadata(key, value) VALUES(?, 'https://api.example/api/')")
.bind(API_NAMESPACE_KEY)
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO media_files(media_id, type, upload_state, pre_progressing_process) VALUES('media-1', 'image', 'backgroundUploadTaskStarted', 73)",
)
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO direct_media_upload_slots
(attachment_id, expires_at, maximum_object_bytes, upload_url,
upload_fields_json, capability, state)
VALUES('attachment-1', 9999999999, 1000000, 'https://objects.example',
'{}', x'01', 'reserved')"#,
)
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO direct_media_upload_jobs
(attachment_id, media_id, multipart_path, manifest_path,
complete_body_path, native_descriptor_json, state, expires_at)
VALUES('attachment-1', 'media-1', ?, ?, ?, '{}', 'scheduled', 9999999999)"#,
)
.bind(multipart.to_string_lossy().as_ref())
.bind(manifest.to_string_lossy().as_ref())
.bind(complete.to_string_lossy().as_ref())
.execute(&database.pool)
.await
.unwrap();
let (previous, restarted) =
reset_upload_namespace(&database, directory.path(), "https://dev-api.example/api/")
.await
.unwrap();
assert_eq!(previous.as_deref(), Some("https://api.example/api/"));
assert_eq!(restarted, 1);
let state = sqlx::query_as::<_, (Option<String>, Option<i64>)>(
"SELECT upload_state, pre_progressing_process FROM media_files WHERE media_id = 'media-1'",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(state, (Some("preprocessing".into()), None));
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM direct_media_upload_jobs")
.fetch_one(&database.pool)
.await
.unwrap(),
0
);
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM direct_media_upload_slots")
.fetch_one(&database.pool)
.await
.unwrap(),
0
);
assert!(!multipart.exists());
assert!(!manifest.exists());
assert!(!complete.exists());
}
#[test]
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
let directory = tempfile::tempdir().unwrap();

View file

@ -0,0 +1,50 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/twonly.db.dart';
void main() {
late TwonlyDB database;
setUp(() {
database = TwonlyDB(NativeDatabase.memory());
});
tearDown(() => database.close());
test('members use user ids and only non-direct group ids', () async {
await database.customStatement(
"INSERT INTO contacts(user_id, username) VALUES(7, 'alice')",
);
await database.customStatement(
"INSERT INTO groups(group_id, group_name, is_direct_chat) "
"VALUES('direct-1', 'Alice', 1)",
);
await database.customStatement(
"INSERT INTO groups(group_id, group_name, is_direct_chat) "
"VALUES('group-1', 'Friends', 0)",
);
final contactGroupId = await database.contactGroupsDao.createContactGroup(
name: 'Favorites',
emoji: '',
textColor: 0xFF000000,
backgroundColor: 0,
showAsShortcut: true,
showAsLabel: true,
);
await database.contactGroupsDao.replaceMembers(
contactGroupId,
userIds: const [7],
groupIds: const ['direct-1', 'group-1'],
);
final members = await database.contactGroupsDao.getMembers(contactGroupId);
expect(members, hasLength(2));
expect(members.where((member) => member.userId == 7), hasLength(1));
expect(
members.where((member) => member.groupId == 'group-1'),
hasLength(1),
);
expect(members.any((member) => member.groupId == 'direct-1'), isFalse);
});
}

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
void main() {
testWidgets('keyboard moves the input without resizing the media', (
tester,
) async {
const mediaKey = Key('media');
const safeAreaBottomPadding = 24.0;
final controller = TextEditingController();
final settings = _TestSettingsChangeProvider();
addTearDown(controller.dispose);
addTearDown(settings.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(360, 690);
addTearDown(tester.view.reset);
Future<void> pumpWithKeyboardInset(double keyboardInset) async {
await tester.pumpWidget(
ChangeNotifierProvider<SettingsChangeProvider>.value(
value: settings,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: MediaQuery(
data: MediaQueryData(
size: const Size(360, 690),
padding: EdgeInsets.only(
bottom: keyboardInset == 0 ? safeAreaBottomPadding : 0,
),
viewPadding: const EdgeInsets.only(
bottom: safeAreaBottomPadding,
),
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: Scaffold(
resizeToAvoidBottomInset: false,
body: SafeArea(
maintainBottomViewPadding: true,
child: Stack(
fit: StackFit.expand,
children: [
const MediaViewSizingHelper(
requiredHeight: 55,
bottomNavigation: SizedBox.shrink(),
child: SizedBox(key: mediaKey),
),
MediaViewerMessageInput(
controller: controller,
safeAreaBottomPadding: safeAreaBottomPadding,
onSubmitted: (_) {},
onSendPressed: () {},
),
],
),
),
),
),
),
),
);
await tester.pump();
}
await pumpWithKeyboardInset(0);
final mediaSizeWithoutKeyboard = tester.getSize(find.byKey(mediaKey));
await pumpWithKeyboardInset(300);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byKey(mediaKey)), mediaSizeWithoutKeyboard);
expect(tester.widget<Positioned>(find.byType(Positioned)).bottom, 276);
});
}
class _TestSettingsChangeProvider extends SettingsChangeProvider {
@override
ThemeMode get themeMode => ThemeMode.light;
@override
Color get primaryColor => Colors.blue;
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
void main() {
testWidgets(
'a contact group without a background has no decoration or padding',
(tester) async {
final contactGroup = ContactGroup(
id: 1,
name: '😀',
textColor: Colors.black.toARGB32(),
backgroundColor: 0x00123456,
showAsShortcut: true,
showAsLabel: true,
usageCounter: 0,
createdAt: DateTime(2026),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ContactGroupBadges(
userId: 1,
contactGroups: [contactGroup],
padding: const EdgeInsets.all(10),
),
),
),
);
final badgeContainer = tester.widget<Container>(
find.descendant(
of: find.byType(ContactGroupBadges),
matching: find.byType(Container),
),
);
expect(badgeContainer.decoration, isNull);
expect(badgeContainer.padding, EdgeInsets.zero);
expect(tester.widget<Text>(find.text('😀')).style?.fontSize, 12);
},
);
}

View file

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/visual/components/custom_color_picker_dialog.comp.dart';
class _TestSettingsChangeProvider extends SettingsChangeProvider {
@override
ThemeMode get themeMode => ThemeMode.light;
}
void main() {
testWidgets('lays out the color wheel in an AlertDialog', (tester) async {
tester.view
..physicalSize = const Size(320, 640)
..devicePixelRatio = 1;
addTearDown(() {
tester.view
..resetPhysicalSize()
..resetDevicePixelRatio();
});
await tester.pumpWidget(
ChangeNotifierProvider<SettingsChangeProvider>(
create: (_) => _TestSettingsChangeProvider(),
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: CustomColorPickerDialog(initialColor: Colors.blue),
),
),
);
expect(tester.takeException(), isNull);
expect(find.byType(Slider), findsNothing);
expect(find.byType(CustomPaint), findsWidgets);
});
}

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
void main() {
const mediaKey = Key('media');
const bottomNavigationKey = Key('bottom-navigation');
Future<void> pumpHelper(
WidgetTester tester, {
required double height,
bool useCameraEditorSizing = false,
}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(360, 900);
addTearDown(tester.view.reset);
final sizingHelper = useCameraEditorSizing
? const MediaViewSizingHelper.cameraEditor(
bottomNavigation: SizedBox(key: bottomNavigationKey),
child: SizedBox(key: mediaKey),
)
: const MediaViewSizingHelper(
requiredHeight: 55,
bottomNavigation: SizedBox(key: bottomNavigationKey),
child: SizedBox(key: mediaKey),
);
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: Size(360, 720)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 360,
height: height,
child: sizingHelper,
),
),
),
),
);
}
testWidgets('shrinks media to leave room for navigation on short devices', (
tester,
) async {
await pumpHelper(tester, height: 672);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byKey(mediaKey)).height, 617);
expect(tester.getSize(find.byKey(bottomNavigationKey)).height, 55);
});
testWidgets('keeps full media aspect ratio when enough height is available', (
tester,
) async {
await pumpHelper(tester, height: 800);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byKey(mediaKey)), const Size(360, 640));
});
testWidgets('camera and editor sizing reserves their shared footer height', (
tester,
) async {
await pumpHelper(tester, height: 672, useCameraEditorSizing: true);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byKey(mediaKey)).height, 613);
expect(tester.getSize(find.byKey(bottomNavigationKey)).height, 59);
});
}