mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-02 02:14:08 +00:00
merge labels and shortcuts to contact groups
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
7d2f1d9f6f
commit
deb712f21d
56 changed files with 4083 additions and 3584 deletions
|
|
@ -47,7 +47,7 @@ class AppEnvironment {
|
||||||
) async {
|
) async {
|
||||||
await destination.create(recursive: true);
|
await destination.create(recursive: true);
|
||||||
final marker = File('${destination.path}/.runtime_storage_migrated_v1');
|
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 for (final entity in source.list(followLinks: false)) {
|
||||||
await _copyEntity(entity, destination.path);
|
await _copyEntity(entity, destination.path);
|
||||||
|
|
@ -62,7 +62,9 @@ class AppEnvironment {
|
||||||
FileSystemEntity entity,
|
FileSystemEntity entity,
|
||||||
String destinationDirectory,
|
String destinationDirectory,
|
||||||
) async {
|
) 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';
|
final destinationPath = '$destinationDirectory/$name';
|
||||||
if (entity is Directory) {
|
if (entity is Directory) {
|
||||||
final destination = Directory(destinationPath);
|
final destination = Directory(destinationPath);
|
||||||
|
|
@ -75,7 +77,7 @@ class AppEnvironment {
|
||||||
if (entity is! File) return;
|
if (entity is! File) return;
|
||||||
|
|
||||||
final destination = File(destinationPath);
|
final destination = File(destinationPath);
|
||||||
if (await destination.exists()) return;
|
if (destination.existsSync()) return;
|
||||||
final temporary = File('$destinationPath.migrating');
|
final temporary = File('$destinationPath.migrating');
|
||||||
await entity.copy(temporary.path);
|
await entity.copy(temporary.path);
|
||||||
await temporary.rename(destination.path);
|
await temporary.rename(destination.path);
|
||||||
|
|
|
||||||
295
lib/src/database/daos/contact_groups.dao.dart
Normal file
295
lib/src/database/daos/contact_groups.dao.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/src/database/daos/contact_groups.dao.g.dart
Normal file
29
lib/src/database/daos/contact_groups.dao.g.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
48
lib/src/database/tables/contact_groups.table.dart
Normal file
48
lib/src/database/tables/contact_groups.table.dart
Normal 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))',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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};
|
|
||||||
}
|
|
||||||
|
|
@ -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};
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
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/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/groups.dao.dart';
|
import 'package:twonly/src/database/daos/groups.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/labels.dao.dart';
|
|
||||||
import 'package:twonly/src/database/daos/mediafiles.dao.dart';
|
import 'package:twonly/src/database/daos/mediafiles.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/messages.dao.dart';
|
import 'package:twonly/src/database/daos/messages.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/reactions.dao.dart';
|
import 'package:twonly/src/database/daos/reactions.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/receipts.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/daos/user_discovery.dao.dart';
|
||||||
import 'package:twonly/src/database/rust_change_notifier.dart';
|
import 'package:twonly/src/database/rust_change_notifier.dart';
|
||||||
import 'package:twonly/src/database/rust_query_executor.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/contacts.table.dart';
|
||||||
import 'package:twonly/src/database/tables/groups.table.dart';
|
import 'package:twonly/src/database/tables/groups.table.dart';
|
||||||
import 'package:twonly/src/database/tables/labels.table.dart';
|
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/tables/reactions.table.dart';
|
import 'package:twonly/src/database/tables/reactions.table.dart';
|
||||||
import 'package:twonly/src/database/tables/receipts.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/tables/user_discovery.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.steps.dart';
|
import 'package:twonly/src/database/twonly.db.steps.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
@ -48,10 +46,8 @@ part 'twonly.db.g.dart';
|
||||||
UserDiscoveryOtherPromotions,
|
UserDiscoveryOtherPromotions,
|
||||||
UserDiscoveryOwnPromotions,
|
UserDiscoveryOwnPromotions,
|
||||||
UserDiscoveryShares,
|
UserDiscoveryShares,
|
||||||
Shortcuts,
|
ContactGroups,
|
||||||
ShortcutMembers,
|
ContactGroupMembers,
|
||||||
Labels,
|
|
||||||
ContactLabels,
|
|
||||||
],
|
],
|
||||||
daos: [
|
daos: [
|
||||||
MessagesDao,
|
MessagesDao,
|
||||||
|
|
@ -62,8 +58,7 @@ part 'twonly.db.g.dart';
|
||||||
MediaFilesDao,
|
MediaFilesDao,
|
||||||
UserDiscoveryDao,
|
UserDiscoveryDao,
|
||||||
KeyVerificationDao,
|
KeyVerificationDao,
|
||||||
ShortcutsDao,
|
ContactGroupsDao,
|
||||||
LabelsDao,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class TwonlyDB extends _$TwonlyDB {
|
class TwonlyDB extends _$TwonlyDB {
|
||||||
|
|
@ -93,6 +88,8 @@ class TwonlyDB extends _$TwonlyDB {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
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(
|
return MigrationStrategy(
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
await customStatement('PRAGMA foreign_keys = ON');
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3362,48 +3362,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Registering new account'**
|
/// **'Registering new account'**
|
||||||
String get registeringNewAccount;
|
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.
|
/// No description provided for @selectEmoji.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Select Emoji'**
|
/// **'Select Emoji'**
|
||||||
String get selectEmoji;
|
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.
|
/// No description provided for @subscriptionPledgeSecureTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -4322,65 +4286,125 @@ abstract class AppLocalizations {
|
||||||
/// **'Show flame restore warning'**
|
/// **'Show flame restore warning'**
|
||||||
String get settingsShowRestoreFlameTitle;
|
String get settingsShowRestoreFlameTitle;
|
||||||
|
|
||||||
/// No description provided for @contactLabelsTitle.
|
/// No description provided for @contactGroupsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Contact Labels'**
|
/// **'Contact Groups'**
|
||||||
String get contactLabelsTitle;
|
String get contactGroupsTitle;
|
||||||
|
|
||||||
/// No description provided for @contactLabelsSubtitleEmpty.
|
/// No description provided for @contactGroupsSubtitleEmpty.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No labels selected'**
|
/// **'No visible contact groups selected'**
|
||||||
String get contactLabelsSubtitleEmpty;
|
String get contactGroupsSubtitleEmpty;
|
||||||
|
|
||||||
/// No description provided for @contactLabelsMaxLimit.
|
/// No description provided for @createContactGroup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Maximum 3 labels per contact'**
|
/// **'Create contact group'**
|
||||||
String get contactLabelsMaxLimit;
|
String get createContactGroup;
|
||||||
|
|
||||||
/// No description provided for @createLabel.
|
/// No description provided for @editContactGroup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Create new label'**
|
/// **'Edit contact group'**
|
||||||
String get createLabel;
|
String get editContactGroup;
|
||||||
|
|
||||||
/// No description provided for @editLabel.
|
/// No description provided for @deleteContactGroup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Edit label'**
|
/// **'Delete contact group'**
|
||||||
String get editLabel;
|
String get deleteContactGroup;
|
||||||
|
|
||||||
/// No description provided for @deleteLabel.
|
/// No description provided for @deleteContactGroupConfirmation.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Delete label'**
|
/// **'Are you sure you want to delete this contact group? It will be removed from all contacts and groups.'**
|
||||||
String get deleteLabel;
|
String get deleteContactGroupConfirmation;
|
||||||
|
|
||||||
/// No description provided for @deleteLabelConfirmation.
|
/// No description provided for @contactGroupTextColor.
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Are you sure you want to delete this label? It will be removed from all contacts.'**
|
|
||||||
String get deleteLabelConfirmation;
|
|
||||||
|
|
||||||
/// No description provided for @labelNameHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Label name'**
|
|
||||||
String get labelNameHint;
|
|
||||||
|
|
||||||
/// No description provided for @labelTextColor.
|
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Text color'**
|
/// **'Text color'**
|
||||||
String get labelTextColor;
|
String get contactGroupTextColor;
|
||||||
|
|
||||||
/// No description provided for @labelBackgroundColor.
|
/// No description provided for @contactGroupBackgroundColor.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Background color'**
|
/// **'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.
|
/// No description provided for @customColor.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -1936,29 +1936,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get registeringNewAccount => 'Neues Konto wird registriert';
|
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
|
@override
|
||||||
String get selectEmoji => 'Emoji auswählen';
|
String get selectEmoji => 'Emoji auswählen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get errorEmojiUsedOrInvalid =>
|
|
||||||
'Emoji wird bereits verwendet oder ist ungültig';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
||||||
|
|
||||||
|
|
@ -2506,35 +2486,69 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
'Hinweis zur Flammen-Wiederherstellung anzeigen';
|
'Hinweis zur Flammen-Wiederherstellung anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsTitle => 'Kontaktlabels';
|
String get contactGroupsTitle => 'Kontaktgruppen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsSubtitleEmpty => 'Keine Labels ausgewählt';
|
String get contactGroupsSubtitleEmpty =>
|
||||||
|
'Keine sichtbaren Kontaktgruppen ausgewählt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsMaxLimit => 'Maximal 3 Labels pro Kontakt';
|
String get createContactGroup => 'Kontaktgruppe erstellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createLabel => 'Neues Label erstellen';
|
String get editContactGroup => 'Kontaktgruppe anpassen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get editLabel => 'Label bearbeiten';
|
String get deleteContactGroup => 'Kontaktgruppe löschen';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get deleteLabelConfirmation =>
|
String get contactGroupTextColor => 'Textfarbe';
|
||||||
'Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get labelNameHint => 'Label-Name';
|
String get contactGroupBackgroundColor => 'Hintergrundfarbe';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get labelTextColor => 'Textfarbe';
|
String get contactGroupNoBackground => 'Kein Hintergrund';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get customColor => 'Eigene Farbe';
|
String get customColor => 'Eigene Farbe';
|
||||||
|
|
|
||||||
|
|
@ -1922,28 +1922,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get registeringNewAccount => 'Registering new account';
|
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
|
@override
|
||||||
String get selectEmoji => 'Select Emoji';
|
String get selectEmoji => 'Select Emoji';
|
||||||
|
|
||||||
@override
|
|
||||||
String get errorEmojiUsedOrInvalid => 'Emoji already used or invalid';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
||||||
|
|
||||||
|
|
@ -2481,35 +2462,68 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get settingsShowRestoreFlameTitle => 'Show flame restore warning';
|
String get settingsShowRestoreFlameTitle => 'Show flame restore warning';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsTitle => 'Contact Labels';
|
String get contactGroupsTitle => 'Contact Groups';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsSubtitleEmpty => 'No labels selected';
|
String get contactGroupsSubtitleEmpty => 'No visible contact groups selected';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactLabelsMaxLimit => 'Maximum 3 labels per contact';
|
String get createContactGroup => 'Create contact group';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createLabel => 'Create new label';
|
String get editContactGroup => 'Edit contact group';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get editLabel => 'Edit label';
|
String get deleteContactGroup => 'Delete contact group';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get deleteLabelConfirmation =>
|
String get contactGroupTextColor => 'Text color';
|
||||||
'Are you sure you want to delete this label? It will be removed from all contacts.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get labelNameHint => 'Label name';
|
String get contactGroupBackgroundColor => 'Background color';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get labelTextColor => 'Text color';
|
String get contactGroupNoBackground => 'No background';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
String get customColor => 'Custom Color';
|
String get customColor => 'Custom Color';
|
||||||
|
|
|
||||||
|
|
@ -687,13 +687,7 @@
|
||||||
"registerNewAccount": "Neues Konto registrieren",
|
"registerNewAccount": "Neues Konto registrieren",
|
||||||
"tryRestoreAgain": "Wiederherstellung erneut versuchen",
|
"tryRestoreAgain": "Wiederherstellung erneut versuchen",
|
||||||
"registeringNewAccount": "Neues Konto wird registriert",
|
"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",
|
"selectEmoji": "Emoji auswählen",
|
||||||
"errorEmojiUsedOrInvalid": "Emoji wird bereits verwendet oder ist ungültig",
|
|
||||||
"subscriptionPledgeSecureTitle": "Secure by Design",
|
"subscriptionPledgeSecureTitle": "Secure by Design",
|
||||||
"subscriptionPledgeSecureDesc": "Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.",
|
"subscriptionPledgeSecureDesc": "Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.",
|
||||||
"subscriptionPledgeNoAdsTitle": "Keine Werbung oder Datenverkauf",
|
"subscriptionPledgeNoAdsTitle": "Keine Werbung oder Datenverkauf",
|
||||||
|
|
@ -937,16 +931,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settingsShowRestoreFlameTitle": "Hinweis zur Flammen-Wiederherstellung anzeigen",
|
"settingsShowRestoreFlameTitle": "Hinweis zur Flammen-Wiederherstellung anzeigen",
|
||||||
"contactLabelsTitle": "Kontaktlabels",
|
"contactGroupsTitle": "Kontaktgruppen",
|
||||||
"contactLabelsSubtitleEmpty": "Keine Labels ausgewählt",
|
"contactGroupsSubtitleEmpty": "Keine sichtbaren Kontaktgruppen ausgewählt",
|
||||||
"contactLabelsMaxLimit": "Maximal 3 Labels pro Kontakt",
|
"createContactGroup": "Kontaktgruppe erstellen",
|
||||||
"createLabel": "Neues Label erstellen",
|
"editContactGroup": "Kontaktgruppe anpassen",
|
||||||
"editLabel": "Label bearbeiten",
|
"deleteContactGroup": "Kontaktgruppe löschen",
|
||||||
"deleteLabel": "Label löschen",
|
"deleteContactGroupConfirmation": "Möchtest du diese Kontaktgruppe wirklich löschen? Sie wird von allen Kontakten und Gruppen entfernt.",
|
||||||
"deleteLabelConfirmation": "Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.",
|
"contactGroupTextColor": "Textfarbe",
|
||||||
"labelNameHint": "Label-Name",
|
"contactGroupBackgroundColor": "Hintergrundfarbe",
|
||||||
"labelTextColor": "Textfarbe",
|
"contactGroupNoBackground": "Kein Hintergrund",
|
||||||
"labelBackgroundColor": "Hintergrundfarbe",
|
"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",
|
"customColor": "Eigene Farbe",
|
||||||
"hue": "Farbton",
|
"hue": "Farbton",
|
||||||
"saturation": "Sättigung",
|
"saturation": "Sättigung",
|
||||||
|
|
|
||||||
|
|
@ -697,13 +697,7 @@
|
||||||
"registerNewAccount": "Register New Account",
|
"registerNewAccount": "Register New Account",
|
||||||
"tryRestoreAgain": "Try Restore Again",
|
"tryRestoreAgain": "Try Restore Again",
|
||||||
"registeringNewAccount": "Registering new account",
|
"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",
|
"selectEmoji": "Select Emoji",
|
||||||
"errorEmojiUsedOrInvalid": "Emoji already used or invalid",
|
|
||||||
"subscriptionPledgeSecureTitle": "Secure by Design",
|
"subscriptionPledgeSecureTitle": "Secure by Design",
|
||||||
"subscriptionPledgeSecureDesc": "Your messages and shared moments are fully end-to-end encrypted.",
|
"subscriptionPledgeSecureDesc": "Your messages and shared moments are fully end-to-end encrypted.",
|
||||||
"subscriptionPledgeNoAdsTitle": "No Ads or Data selling",
|
"subscriptionPledgeNoAdsTitle": "No Ads or Data selling",
|
||||||
|
|
@ -947,16 +941,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settingsShowRestoreFlameTitle": "Show flame restore warning",
|
"settingsShowRestoreFlameTitle": "Show flame restore warning",
|
||||||
"contactLabelsTitle": "Contact Labels",
|
"contactGroupsTitle": "Contact Groups",
|
||||||
"contactLabelsSubtitleEmpty": "No labels selected",
|
"contactGroupsSubtitleEmpty": "No visible contact groups selected",
|
||||||
"contactLabelsMaxLimit": "Maximum 3 labels per contact",
|
"createContactGroup": "Create contact group",
|
||||||
"createLabel": "Create new label",
|
"editContactGroup": "Edit contact group",
|
||||||
"editLabel": "Edit label",
|
"deleteContactGroup": "Delete contact group",
|
||||||
"deleteLabel": "Delete label",
|
"deleteContactGroupConfirmation": "Are you sure you want to delete this contact group? It will be removed from all contacts and groups.",
|
||||||
"deleteLabelConfirmation": "Are you sure you want to delete this label? It will be removed from all contacts.",
|
"contactGroupTextColor": "Text color",
|
||||||
"labelNameHint": "Label name",
|
"contactGroupBackgroundColor": "Background color",
|
||||||
"labelTextColor": "Text color",
|
"contactGroupNoBackground": "No background",
|
||||||
"labelBackgroundColor": "Background color",
|
"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",
|
"customColor": "Custom Color",
|
||||||
"hue": "Hue",
|
"hue": "Hue",
|
||||||
"saturation": "Saturation",
|
"saturation": "Saturation",
|
||||||
|
|
|
||||||
279
lib/src/visual/components/contact_groups.comp.dart
Normal file
279
lib/src/visual/components/contact_groups.comp.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
|
@ -21,79 +23,74 @@ class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentColor = _hsvColor.toColor();
|
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(
|
return AlertDialog(
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
title: Text(context.lang.customColor),
|
title: Text(context.lang.customColor),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Color preview box
|
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: SizedBox.square(
|
||||||
width: 80,
|
dimension: wheelSize,
|
||||||
height: 80,
|
child: Semantics(
|
||||||
decoration: BoxDecoration(
|
label: context.lang.customColor,
|
||||||
color: currentColor,
|
child: GestureDetector(
|
||||||
borderRadius: BorderRadius.circular(16),
|
behavior: HitTestBehavior.opaque,
|
||||||
boxShadow: [
|
onPanDown: (details) =>
|
||||||
BoxShadow(
|
_selectColor(details.localPosition, colorWheelSize),
|
||||||
color: Colors.black.withValues(alpha: 0.15),
|
onPanUpdate: (details) =>
|
||||||
blurRadius: 8,
|
_selectColor(details.localPosition, colorWheelSize),
|
||||||
offset: const Offset(0, 4),
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,6 +9,13 @@ class MediaViewSizingHelper extends StatefulWidget {
|
||||||
this.additionalPadding,
|
this.additionalPadding,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MediaViewSizingHelper.cameraEditor({
|
||||||
|
required this.child,
|
||||||
|
required this.bottomNavigation,
|
||||||
|
super.key,
|
||||||
|
}) : requiredHeight = 59,
|
||||||
|
additionalPadding = null;
|
||||||
|
|
||||||
final double? requiredHeight;
|
final double? requiredHeight;
|
||||||
final double? additionalPadding;
|
final double? additionalPadding;
|
||||||
final Widget? bottomNavigation;
|
final Widget? bottomNavigation;
|
||||||
|
|
@ -21,37 +28,19 @@ class MediaViewSizingHelper extends StatefulWidget {
|
||||||
class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var needToDownSizeImage = false;
|
return SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
// Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes
|
builder: (context, constraints) {
|
||||||
final screenSize = MediaQuery.sizeOf(context);
|
final availableWidth = constraints.maxWidth;
|
||||||
final safeAreaPadding = MediaQuery.paddingOf(context);
|
|
||||||
|
|
||||||
// Calculate the available width and height
|
|
||||||
final availableWidth = screenSize.width;
|
|
||||||
final availableHeight =
|
final availableHeight =
|
||||||
screenSize.height -
|
constraints.maxHeight - (widget.additionalPadding ?? 0);
|
||||||
safeAreaPadding.top -
|
final aspectRatioHeight = (availableWidth * 16) / 9;
|
||||||
safeAreaPadding.bottom -
|
final bottomNavigationHeight = widget.requiredHeight ?? 0;
|
||||||
(widget.additionalPadding ?? 0);
|
final needToDownSizeImage =
|
||||||
|
aspectRatioHeight + bottomNavigationHeight > availableHeight;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget imageChild = Align(
|
Widget imageChild = Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: SizedBox(
|
|
||||||
// height: availableHeight,
|
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 9 / 16,
|
aspectRatio: 9 / 16,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|
@ -59,10 +48,9 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget bottomNavigation = Container();
|
Widget bottomNavigation = const SizedBox.shrink();
|
||||||
|
|
||||||
if (widget.bottomNavigation != null) {
|
if (widget.bottomNavigation != null) {
|
||||||
if (needToDownSizeImage) {
|
if (needToDownSizeImage) {
|
||||||
|
|
@ -76,14 +64,13 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return Container(
|
||||||
child: Container(
|
constraints: BoxConstraints(maxHeight: availableHeight),
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxHeight: availableHeight,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [imageChild, bottomNavigation],
|
children: [imageChild, bottomNavigation],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
const defaultPrimaryColor = Color(0xFF57CC99);
|
const defaultPrimaryColor = Color(0xFF32be80);
|
||||||
|
|
||||||
ThemeData getLightTheme([Color primary = defaultPrimaryColor]) {
|
ThemeData getLightTheme([Color primary = defaultPrimaryColor]) {
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,10 +19,8 @@ class MainCameraPreview extends StatelessWidget {
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
child: MediaViewSizingHelper(
|
child: MediaViewSizingHelper.cameraEditor(
|
||||||
requiredHeight: 0,
|
bottomNavigation: const SizedBox.shrink(),
|
||||||
additionalPadding: 59,
|
|
||||||
bottomNavigation: Container(),
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Screenshot(
|
Screenshot(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart';
|
import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart';
|
||||||
import 'package:flutter_volume_controller/flutter_volume_controller.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:image_picker/image_picker.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/globals.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_budget.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_time.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.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';
|
import 'package:twonly/src/visual/views/home.view.dart';
|
||||||
|
|
||||||
class SelectedCameraDetails {
|
class SelectedCameraDetails {
|
||||||
|
|
@ -764,10 +762,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: userService.onUserUpdated,
|
stream: userService.onUserUpdated,
|
||||||
builder: (context, asyncSnapshot) {
|
builder: (context, asyncSnapshot) {
|
||||||
return MediaViewSizingHelper(
|
return MediaViewSizingHelper.cameraEditor(
|
||||||
requiredHeight: 0,
|
bottomNavigation: const SizedBox.shrink(),
|
||||||
additionalPadding: 59,
|
|
||||||
bottomNavigation: Container(),
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onPanStart: (details) async {
|
onPanStart: (details) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -891,19 +887,6 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
currentTime: _currentTime,
|
currentTime: _currentTime,
|
||||||
maxRecordingTime: _currentMaxRecordingTime,
|
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(),
|
if (_showSelfieFlash) const CameraSelfieFlash(),
|
||||||
CameraScannedOverlay(mainController: mc),
|
CameraScannedOverlay(mainController: mc),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,14 @@ class MainCameraController {
|
||||||
GlobalKey cameraPreviewKey = GlobalKey();
|
GlobalKey cameraPreviewKey = GlobalKey();
|
||||||
|
|
||||||
bool isSelectingFaceFilters = false;
|
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;
|
bool isVideoRecording = false;
|
||||||
DateTime? timeSharedLinkWasSetWithQr;
|
DateTime? timeSharedLinkWasSetWithQr;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,10 @@ class CameraSendToViewState extends State<CameraSendToView> {
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Always-visible back button so the user can exit regardless of
|
// Keep this route's close button available during camera startup and
|
||||||
// camera state (e.g. during init or after init failure).
|
// failures, but hide it beneath the transparent editor, which has
|
||||||
|
// its own close action.
|
||||||
|
if (!_mainCameraController.isSharePreviewIsShown)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 5,
|
left: 5,
|
||||||
top: MediaQuery.paddingOf(context).top + 10,
|
top: MediaQuery.paddingOf(context).top + 10,
|
||||||
|
|
|
||||||
|
|
@ -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/elements/my_button.element.dart';
|
||||||
import 'package:twonly/src/visual/helpers/screenshot.helper.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/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/camera/share_image_editor_components/layers/background.layer.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.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),
|
const SizedBox(height: 10),
|
||||||
ShortcutRowComp(
|
ContactGroupShortcutRow(
|
||||||
selectedGroupIds: widget.selectedGroupIds,
|
selectedGroupIds: widget.selectedGroupIds,
|
||||||
updateSelectedGroupIds: updateSelectedGroupIds,
|
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -433,8 +433,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: _onCanvasTap,
|
onTap: _onCanvasTap,
|
||||||
child: MediaViewSizingHelper(
|
child: MediaViewSizingHelper.cameraEditor(
|
||||||
requiredHeight: 59,
|
|
||||||
bottomNavigation: EditorBottomBar(
|
bottomNavigation: EditorBottomBar(
|
||||||
mediaService: mediaService,
|
mediaService: mediaService,
|
||||||
sendToGroup: widget.sendToGroup,
|
sendToGroup: widget.sendToGroup,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
StreamSubscription<List<Message>>? _latestMessagesSub;
|
StreamSubscription<List<Message>>? _latestMessagesSub;
|
||||||
StreamSubscription<List<MediaFile>>? _chatListMediaSub;
|
StreamSubscription<List<MediaFile>>? _chatListMediaSub;
|
||||||
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
||||||
StreamSubscription<List<(int, Label)>>? _contactLabelsSub;
|
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
|
||||||
|
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
|
||||||
Timer? _typingUpdateTimer;
|
Timer? _typingUpdateTimer;
|
||||||
final Set<String> _precachedMediaIds = {};
|
final Set<String> _precachedMediaIds = {};
|
||||||
List<Group> _groupsNotPinned = [];
|
List<Group> _groupsNotPinned = [];
|
||||||
|
|
@ -58,7 +59,8 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
Map<String, Message> _lastMessageByGroup = {};
|
Map<String, Message> _lastMessageByGroup = {};
|
||||||
Map<String, MediaFile> _chatListMediaById = {};
|
Map<String, MediaFile> _chatListMediaById = {};
|
||||||
Map<String, VerificationStatus> _verificationByGroup = {};
|
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);
|
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
@ -207,16 +209,39 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _verificationByGroup = statuses);
|
setState(() => _verificationByGroup = statuses);
|
||||||
});
|
});
|
||||||
_contactLabelsSub = twonlyDB.labelsDao.watchAllContactLabels().listen((
|
_contactGroupsSub = twonlyDB.contactGroupsDao
|
||||||
|
.watchAllVisibleUserGroups()
|
||||||
|
.listen((
|
||||||
rows,
|
rows,
|
||||||
) {
|
) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final labels = <int, List<Label>>{};
|
final contactGroups = <int, List<ContactGroup>>{};
|
||||||
for (final row in rows) {
|
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
|
@override
|
||||||
|
|
@ -238,7 +263,8 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
_latestMessagesSub?.cancel();
|
_latestMessagesSub?.cancel();
|
||||||
_chatListMediaSub?.cancel();
|
_chatListMediaSub?.cancel();
|
||||||
_verificationSub?.cancel();
|
_verificationSub?.cancel();
|
||||||
_contactLabelsSub?.cancel();
|
_contactGroupsSub?.cancel();
|
||||||
|
_chatContactGroupsSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,13 +445,7 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
mediaFiles: _mediaForGroup(group.groupId),
|
mediaFiles: _mediaForGroup(group.groupId),
|
||||||
useSharedSummary: true,
|
useSharedSummary: true,
|
||||||
verificationStatus: _verificationByGroup[group.groupId],
|
verificationStatus: _verificationByGroup[group.groupId],
|
||||||
contactLabels:
|
contactGroups: _contactGroupsFor(group),
|
||||||
_contactsByGroup[group.groupId]?.isNotEmpty == true
|
|
||||||
? _labelsByContact[_contactsByGroup[group.groupId]!
|
|
||||||
.first
|
|
||||||
.userId] ??
|
|
||||||
const []
|
|
||||||
: const [],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,13 +474,7 @@ class _ChatListViewState extends State<ChatListView>
|
||||||
mediaFiles: _mediaForGroup(group.groupId),
|
mediaFiles: _mediaForGroup(group.groupId),
|
||||||
useSharedSummary: true,
|
useSharedSummary: true,
|
||||||
verificationStatus: _verificationByGroup[group.groupId],
|
verificationStatus: _verificationByGroup[group.groupId],
|
||||||
contactLabels:
|
contactGroups: _contactGroupsFor(group),
|
||||||
_contactsByGroup[group.groupId]?.isNotEmpty == true
|
|
||||||
? _labelsByContact[_contactsByGroup[group.groupId]!
|
|
||||||
.first
|
|
||||||
.userId] ??
|
|
||||||
const []
|
|
||||||
: const [],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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/services/mediafiles/media_download_policy.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
||||||
|
|
@ -32,7 +32,7 @@ class GroupListItemComp extends StatefulWidget {
|
||||||
this.mediaFiles,
|
this.mediaFiles,
|
||||||
this.useSharedSummary = false,
|
this.useSharedSummary = false,
|
||||||
this.verificationStatus,
|
this.verificationStatus,
|
||||||
this.contactLabels = const [],
|
this.contactGroups = const [],
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final Group group;
|
final Group group;
|
||||||
|
|
@ -44,7 +44,7 @@ class GroupListItemComp extends StatefulWidget {
|
||||||
final Map<String, MediaFile>? mediaFiles;
|
final Map<String, MediaFile>? mediaFiles;
|
||||||
final bool useSharedSummary;
|
final bool useSharedSummary;
|
||||||
final VerificationStatus? verificationStatus;
|
final VerificationStatus? verificationStatus;
|
||||||
final List<Label> contactLabels;
|
final List<ContactGroup> contactGroups;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GroupListItemComp> createState() => _UserListItem();
|
State<GroupListItemComp> createState() => _UserListItem();
|
||||||
|
|
@ -324,9 +324,19 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
return GroupContextMenu(
|
return GroupContextMenu(
|
||||||
group: widget.group,
|
group: widget.group,
|
||||||
child: ListTile(
|
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: [
|
children: [
|
||||||
Flexible(
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: nameMaxWidth),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.group.groupName,
|
widget.group.groupName,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
@ -342,20 +352,21 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
clickable: false,
|
clickable: false,
|
||||||
size: 12,
|
size: 12,
|
||||||
),
|
),
|
||||||
if (widget.group.isDirectChat && _directContact != null) ...[
|
if (showBadges) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Flexible(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: ContactGroupBadges(
|
||||||
scrollDirection: Axis.horizontal,
|
userId: _directContact?.userId,
|
||||||
physics: const BouncingScrollPhysics(),
|
groupId: widget.group.isDirectChat
|
||||||
child: ContactLabels(
|
? null
|
||||||
contactId: _directContact!.userId,
|
: widget.group.groupId,
|
||||||
labels: widget.contactLabels,
|
contactGroups: widget.contactGroups,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
subtitle: _receiverDeletedAccount
|
subtitle: _receiverDeletedAccount
|
||||||
? Text(context.lang.userDeletedAccount)
|
? Text(context.lang.userDeletedAccount)
|
||||||
|
|
|
||||||
|
|
@ -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/services/notifications/native.notifications.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/themes/colors.dart';
|
import 'package:twonly/src/visual/themes/colors.dart';
|
||||||
|
|
@ -626,9 +626,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
FlameCounterWidget(group: group),
|
FlameCounterWidget(group: group),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (group.isDirectChat && _groupContacts.isNotEmpty)
|
if (!group.isDirectChat)
|
||||||
ContactLabels(
|
ContactGroupBadges(groupId: group.groupId)
|
||||||
contactId: _groupContacts.first.userId,
|
else if (_groupContacts.isNotEmpty)
|
||||||
|
ContactGroupBadges(
|
||||||
|
userId: _groupContacts.first.userId,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -662,7 +662,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
maintainBottomViewPadding: true,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -738,6 +740,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
if (showSendTextMessageInput)
|
if (showSendTextMessageInput)
|
||||||
MediaViewerMessageInput(
|
MediaViewerMessageInput(
|
||||||
controller: textMessageController,
|
controller: textMessageController,
|
||||||
|
safeAreaBottomPadding: MediaQuery.viewPaddingOf(
|
||||||
|
context,
|
||||||
|
).bottom,
|
||||||
onSubmitted: (value) => _sendTextMessage(),
|
onSubmitted: (value) => _sendTextMessage(),
|
||||||
onSendPressed: _sendTextMessage,
|
onSendPressed: _sendTextMessage,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,26 @@ import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||||
class MediaViewerMessageInput extends StatelessWidget {
|
class MediaViewerMessageInput extends StatelessWidget {
|
||||||
const MediaViewerMessageInput({
|
const MediaViewerMessageInput({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
required this.safeAreaBottomPadding,
|
||||||
required this.onSubmitted,
|
required this.onSubmitted,
|
||||||
required this.onSendPressed,
|
required this.onSendPressed,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
|
final double safeAreaBottomPadding;
|
||||||
final ValueChanged<String> onSubmitted;
|
final ValueChanged<String> onSubmitted;
|
||||||
final VoidCallback onSendPressed;
|
final VoidCallback onSendPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final keyboardBottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
final bottomOffset = keyboardBottomInset > safeAreaBottomPadding
|
||||||
|
? keyboardBottomInset - safeAreaBottomPadding
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
bottom: 0,
|
bottom: bottomOffset,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
|
||||||
|
|
@ -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/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
||||||
|
|
@ -221,8 +221,8 @@ class _StartNewChatView extends State<StartNewChatView> {
|
||||||
return UserContextMenu(
|
return UserContextMenu(
|
||||||
key: ValueKey(contact.userId),
|
key: ValueKey(contact.userId),
|
||||||
contact: contact,
|
contact: contact,
|
||||||
child: ContactLabelsSubtitleBuilder(
|
child: ContactGroupsSubtitleBuilder(
|
||||||
contactId: contact.userId,
|
userId: contact.userId,
|
||||||
builder: (context, subtitleWidget) {
|
builder: (context, subtitleWidget) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
|
@ -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/restore_flame.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/contact/contact_components/user_discovery_contact_settings.comp.dart';
|
import 'package:twonly/src/visual/views/contact/contact_components/user_discovery_contact_settings.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart';
|
import 'package:twonly/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/contact/select_contact_labels.view.dart';
|
import 'package:twonly/src/visual/views/contact/select_contact_groups.view.dart';
|
||||||
import 'package:twonly/src/visual/views/groups/group.view.dart';
|
import 'package:twonly/src/visual/views/groups/group.view.dart';
|
||||||
|
|
||||||
class ContactView extends StatefulWidget {
|
class ContactView extends StatefulWidget {
|
||||||
|
|
@ -230,16 +230,16 @@ class _ContactViewState extends State<ContactView> {
|
||||||
userService.currentUser.userId,
|
userService.currentUser.userId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ContactLabelsSubtitleBuilder(
|
ContactGroupsSubtitleBuilder(
|
||||||
contactId: contact.userId,
|
userId: contact.userId,
|
||||||
builder: (context, subtitleWidget) {
|
builder: (context, subtitleWidget) {
|
||||||
return BetterListTile(
|
return BetterListTile(
|
||||||
icon: FontAwesomeIcons.tag,
|
icon: FontAwesomeIcons.tag,
|
||||||
text: context.lang.contactLabelsTitle,
|
text: context.lang.contactGroupsTitle,
|
||||||
subtitle: subtitleWidget,
|
subtitle: subtitleWidget,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.navPush(
|
context.navPush(
|
||||||
SelectContactLabelsView(contactId: contact.userId),
|
SelectContactGroupsView(userId: contact.userId),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
571
lib/src/visual/views/contact/contact_group_settings.view.dart
Normal file
571
lib/src/visual/views/contact/contact_group_settings.view.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
lib/src/visual/views/contact/select_contact_groups.view.dart
Normal file
253
lib/src/visual/views/contact/select_contact_groups.view.dart
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,13 +12,14 @@ import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/elements/better_list_title.element.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/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_create_select_members.view.dart';
|
||||||
import 'package:twonly/src/visual/views/groups/group_member.context.dart';
|
import 'package:twonly/src/visual/views/groups/group_member.context.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
|
import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
|
||||||
|
|
@ -212,6 +213,21 @@ class _GroupViewState extends State<GroupView> {
|
||||||
groupId: widget.groupId,
|
groupId: widget.groupId,
|
||||||
disabled: !_group!.isGroupAdmin,
|
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(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Padding(
|
title: Padding(
|
||||||
|
|
@ -254,8 +270,8 @@ class _GroupViewState extends State<GroupView> {
|
||||||
group: _group!,
|
group: _group!,
|
||||||
contact: member.$1,
|
contact: member.$1,
|
||||||
member: member.$2,
|
member: member.$2,
|
||||||
child: ContactLabelsSubtitleBuilder(
|
child: ContactGroupsSubtitleBuilder(
|
||||||
contactId: member.$1.userId,
|
userId: member.$1.userId,
|
||||||
builder: (context, subtitleWidget) {
|
builder: (context, subtitleWidget) {
|
||||||
return BetterListTile(
|
return BetterListTile(
|
||||||
padding: const EdgeInsets.only(left: 13),
|
padding: const EdgeInsets.only(left: 13),
|
||||||
|
|
|
||||||
|
|
@ -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/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
|
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
|
||||||
|
|
@ -126,8 +126,8 @@ class _GroupCreateSelectGroupNameViewState
|
||||||
return UserContextMenu(
|
return UserContextMenu(
|
||||||
key: ValueKey(user.userId),
|
key: ValueKey(user.userId),
|
||||||
contact: user,
|
contact: user,
|
||||||
child: ContactLabelsSubtitleBuilder(
|
child: ContactGroupsSubtitleBuilder(
|
||||||
contactId: user.userId,
|
userId: user.userId,
|
||||||
builder: (context, subtitleWidget) {
|
builder: (context, subtitleWidget) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
|
|
|
||||||
|
|
@ -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/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
|
|
@ -219,8 +219,8 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
|
||||||
return UserContextMenu(
|
return UserContextMenu(
|
||||||
key: ValueKey(user.userId),
|
key: ValueKey(user.userId),
|
||||||
contact: user,
|
contact: user,
|
||||||
child: ContactLabelsSubtitleBuilder(
|
child: ContactGroupsSubtitleBuilder(
|
||||||
contactId: user.userId,
|
userId: user.userId,
|
||||||
additionalSubtitle:
|
additionalSubtitle:
|
||||||
alreadyInGroup.contains(user.userId)
|
alreadyInGroup.contains(user.userId)
|
||||||
? Text(context.lang.alreadyInGroup)
|
? Text(context.lang.alreadyInGroup)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.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/better_list_title.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.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/backup_utils.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/backup/components/recovery_card.comp.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';
|
import 'package:twonly/src/visual/views/settings/backup/memories_backup_detail.view.dart';
|
||||||
|
|
@ -185,7 +186,10 @@ class _BackupViewState extends State<BackupView> {
|
||||||
_backupStatus?.archiveSize,
|
_backupStatus?.archiveSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
trailing: const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: defaultPrimaryColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bilder & Medien
|
// Bilder & Medien
|
||||||
|
|
|
||||||
|
|
@ -492,7 +492,24 @@ mod tests {
|
||||||
crate::database::app::AppDatabase::new(&legacy_path.display().to_string(), None, false)
|
crate::database::app::AppDatabase::new(&legacy_path.display().to_string(), None, false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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"#)
|
sqlx::query!(r#"PRAGMA user_version = 25"#)
|
||||||
.execute(&legacy.pool)
|
.execute(&legacy.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -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 crate::error::{Result, TwonlyError};
|
||||||
use sqlx::{Acquire, AssertSqlSafe, Row};
|
use sqlx::{Acquire, AssertSqlSafe, Row};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
@ -114,7 +116,7 @@ async fn import(
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let mut counts = Vec::with_capacity(APPLICATION_TABLES.len());
|
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?;
|
let columns = common_columns(&mut tx, table).await?;
|
||||||
if columns.is_empty() {
|
if columns.is_empty() {
|
||||||
return Err(TwonlyError::Generic(format!(
|
return Err(TwonlyError::Generic(format!(
|
||||||
|
|
@ -166,6 +168,7 @@ async fn import(
|
||||||
rows: source_count,
|
rows: source_count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
migrate_legacy_contact_groups(&mut tx, &mut counts).await?;
|
||||||
copy_sequences(&mut tx).await?;
|
copy_sequences(&mut tx).await?;
|
||||||
let foreign_key_errors =
|
let foreign_key_errors =
|
||||||
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM pragma_foreign_key_check"#)
|
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(
|
async fn common_columns(
|
||||||
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
table: &str,
|
table: &str,
|
||||||
|
|
@ -239,8 +316,7 @@ async fn copy_sequences(connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>) ->
|
||||||
"verification_tokens",
|
"verification_tokens",
|
||||||
"user_discovery_own_promotions",
|
"user_discovery_own_promotions",
|
||||||
"user_discovery_shares",
|
"user_discovery_shares",
|
||||||
"shortcuts",
|
"contact_groups",
|
||||||
"labels",
|
|
||||||
] {
|
] {
|
||||||
let source_sequence: Option<i64> =
|
let source_sequence: Option<i64> =
|
||||||
sqlx::query_scalar(r#"SELECT seq FROM legacy.sqlite_sequence WHERE name = ?"#)
|
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)
|
let legacy = AppDatabase::new(legacy_path.to_str().unwrap(), None, false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
legacy.run_migrations().await.unwrap();
|
create_legacy_schema(&legacy.pool).await;
|
||||||
sqlx::query!(r#"PRAGMA user_version = 25"#)
|
sqlx::query!(r#"PRAGMA user_version = 25"#)
|
||||||
.execute(&legacy.pool)
|
.execute(&legacy.pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -294,11 +370,51 @@ mod tests {
|
||||||
target.run_migrations().await.unwrap();
|
target.run_migrations().await.unwrap();
|
||||||
let first = target.import_legacy(&legacy_path).await.unwrap();
|
let first = target.import_legacy(&legacy_path).await.unwrap();
|
||||||
assert_eq!(first.tables.len(), APPLICATION_TABLES.len());
|
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();
|
let second = target.import_legacy(&legacy_path).await.unwrap();
|
||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
assert!(target.is_legacy_import_complete().await.unwrap());
|
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
|
let selected = target
|
||||||
.raw_select(
|
.raw_select(
|
||||||
"SELECT username, avatar_svg_compressed FROM contacts WHERE user_id = ?".to_owned(),
|
"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) {
|
async fn populate_every_table(pool: &SqlitePool) {
|
||||||
let statements = [
|
let statements = [
|
||||||
"INSERT INTO contacts(user_id, username, avatar_svg_compressed, signal_version) VALUES(7, 'alice', x'0001FF', 'v2')",
|
"INSERT INTO contacts(user_id, username, avatar_svg_compressed, signal_version) VALUES(7, 'alice', x'0001FF', 'v2')",
|
||||||
|
|
|
||||||
100
rust/src/database/app/migrations/0012_contact_groups.sql
Normal file
100
rust/src/database/app/migrations/0012_contact_groups.sql
Normal 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;
|
||||||
|
|
@ -16,12 +16,10 @@ mod legacy_import;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
|
|
||||||
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
|
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
|
/// User-owned application tables in the current Rust schema. Rust-only outbox
|
||||||
/// Drift schema 25, because a missing table aborts the whole import. Rust-only
|
/// tables are deliberately absent because they are reconstructed locally.
|
||||||
/// tables such as `notification_outbox` are deliberately absent: they have no
|
|
||||||
/// legacy counterpart, and importing stale rows would replay old notifications.
|
|
||||||
pub const APPLICATION_TABLES: &[&str] = &[
|
pub const APPLICATION_TABLES: &[&str] = &[
|
||||||
"contacts",
|
"contacts",
|
||||||
"groups",
|
"groups",
|
||||||
|
|
@ -41,10 +39,31 @@ pub const APPLICATION_TABLES: &[&str] = &[
|
||||||
"user_discovery_other_promotions",
|
"user_discovery_other_promotions",
|
||||||
"user_discovery_own_promotions",
|
"user_discovery_own_promotions",
|
||||||
"user_discovery_shares",
|
"user_discovery_shares",
|
||||||
"shortcuts",
|
"contact_groups",
|
||||||
"shortcut_members",
|
"contact_group_members",
|
||||||
"labels",
|
];
|
||||||
"contact_labels",
|
|
||||||
|
/// 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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::api::proto::http_requests::{
|
||||||
};
|
};
|
||||||
use crate::bridge::api::RustApi;
|
use crate::bridge::api::RustApi;
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::database::app::tables::MediaFile;
|
use crate::database::app::{tables::MediaFile, AppDatabase};
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::native::transfer;
|
use crate::native::transfer;
|
||||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||||
|
|
@ -34,10 +34,16 @@ const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
|
||||||
const DEFAULT_SLOT_REFILL_THRESHOLD: i64 = 5;
|
const DEFAULT_SLOT_REFILL_THRESHOLD: i64 = 5;
|
||||||
/// Where the server's advertised refill threshold is remembered between runs.
|
/// Where the server's advertised refill threshold is remembered between runs.
|
||||||
const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
|
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_FIRST_DELAY: Duration = Duration::from_secs(2);
|
||||||
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
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
|
/// 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.
|
/// reads this as "new work arrived" and drops back to the short poll interval.
|
||||||
|
|
@ -180,6 +186,68 @@ struct NativeUploadDescriptor {
|
||||||
complete: NativeRequest,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
enum Outcome {
|
enum Outcome {
|
||||||
Uploaded,
|
Uploaded,
|
||||||
|
|
@ -200,6 +268,34 @@ impl DirectMediaUploadService {
|
||||||
format!("{}{}", RustApi::api_base_url("https".into()), path)
|
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(
|
async fn authenticated_request(
|
||||||
&self,
|
&self,
|
||||||
request: reqwest::RequestBuilder,
|
request: reqwest::RequestBuilder,
|
||||||
|
|
@ -230,6 +326,7 @@ impl DirectMediaUploadService {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preload_slots(&self) -> Result<usize> {
|
pub async fn preload_slots(&self) -> Result<usize> {
|
||||||
|
self.ensure_api_namespace().await?;
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
let known = sqlx::query_scalar::<_, String>(
|
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)",
|
"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<()> {
|
async fn ensure_slots(&self) -> Result<()> {
|
||||||
|
self.ensure_api_namespace().await?;
|
||||||
let usable = self.usable_slot_count().await?;
|
let usable = self.usable_slot_count().await?;
|
||||||
if usable > self.refill_threshold().await {
|
if usable > self.refill_threshold().await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -391,6 +489,7 @@ impl DirectMediaUploadService {
|
||||||
/// while the manifest is still being reconciled, so only the attachment's
|
/// while the manifest is still being reconciled, so only the attachment's
|
||||||
/// own state says whether the recipients were dispatched.
|
/// own state says whether the recipients were dispatched.
|
||||||
pub async fn reconcile(&self) -> Result<()> {
|
pub async fn reconcile(&self) -> Result<()> {
|
||||||
|
self.ensure_api_namespace().await?;
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
let jobs = sqlx::query_as::<_, PendingJob>(
|
let jobs = sqlx::query_as::<_, PendingJob>(
|
||||||
r#"SELECT j.attachment_id, j.media_id, j.multipart_path, j.manifest_path,
|
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));
|
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]
|
#[test]
|
||||||
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
|
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
50
test/drift/contact_groups_dao_test.dart
Normal file
50
test/drift/contact_groups_dao_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
88
test/visual/chats/media_viewer_keyboard_layout_test.dart
Normal file
88
test/visual/chats/media_viewer_keyboard_layout_test.dart
Normal 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;
|
||||||
|
}
|
||||||
45
test/visual/components/contact_groups_test.dart
Normal file
45
test/visual/components/contact_groups_test.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
39
test/visual/components/custom_color_picker_dialog_test.dart
Normal file
39
test/visual/components/custom_color_picker_dialog_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
74
test/visual/helpers/media_view_sizing_helper_test.dart
Normal file
74
test/visual/helpers/media_view_sizing_helper_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue