mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-02 00:24:07 +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 {
|
||||
await destination.create(recursive: true);
|
||||
final marker = File('${destination.path}/.runtime_storage_migrated_v1');
|
||||
if (await marker.exists() || !await source.exists()) return;
|
||||
if (marker.existsSync() || !source.existsSync()) return;
|
||||
|
||||
await for (final entity in source.list(followLinks: false)) {
|
||||
await _copyEntity(entity, destination.path);
|
||||
|
|
@ -62,7 +62,9 @@ class AppEnvironment {
|
|||
FileSystemEntity entity,
|
||||
String destinationDirectory,
|
||||
) async {
|
||||
final name = entity.uri.pathSegments.where((value) => value.isNotEmpty).last;
|
||||
final name = entity.uri.pathSegments
|
||||
.where((value) => value.isNotEmpty)
|
||||
.last;
|
||||
final destinationPath = '$destinationDirectory/$name';
|
||||
if (entity is Directory) {
|
||||
final destination = Directory(destinationPath);
|
||||
|
|
@ -75,7 +77,7 @@ class AppEnvironment {
|
|||
if (entity is! File) return;
|
||||
|
||||
final destination = File(destinationPath);
|
||||
if (await destination.exists()) return;
|
||||
if (destination.existsSync()) return;
|
||||
final temporary = File('$destinationPath.migrating');
|
||||
await entity.copy(temporary.path);
|
||||
await temporary.rename(destination.path);
|
||||
|
|
|
|||
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 'package:drift/drift.dart';
|
||||
import 'package:twonly/src/database/daos/contact_groups.dao.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/groups.dao.dart';
|
||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||
import 'package:twonly/src/database/daos/labels.dao.dart';
|
||||
import 'package:twonly/src/database/daos/mediafiles.dao.dart';
|
||||
import 'package:twonly/src/database/daos/messages.dao.dart';
|
||||
import 'package:twonly/src/database/daos/reactions.dao.dart';
|
||||
import 'package:twonly/src/database/daos/receipts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/shortcuts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
|
||||
import 'package:twonly/src/database/rust_change_notifier.dart';
|
||||
import 'package:twonly/src/database/rust_query_executor.dart';
|
||||
import 'package:twonly/src/database/tables/contact_groups.table.dart';
|
||||
import 'package:twonly/src/database/tables/contacts.table.dart';
|
||||
import 'package:twonly/src/database/tables/groups.table.dart';
|
||||
import 'package:twonly/src/database/tables/labels.table.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/tables/reactions.table.dart';
|
||||
import 'package:twonly/src/database/tables/receipts.table.dart';
|
||||
import 'package:twonly/src/database/tables/shortcuts.table.dart';
|
||||
import 'package:twonly/src/database/tables/user_discovery.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.steps.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
|
@ -48,10 +46,8 @@ part 'twonly.db.g.dart';
|
|||
UserDiscoveryOtherPromotions,
|
||||
UserDiscoveryOwnPromotions,
|
||||
UserDiscoveryShares,
|
||||
Shortcuts,
|
||||
ShortcutMembers,
|
||||
Labels,
|
||||
ContactLabels,
|
||||
ContactGroups,
|
||||
ContactGroupMembers,
|
||||
],
|
||||
daos: [
|
||||
MessagesDao,
|
||||
|
|
@ -62,8 +58,7 @@ part 'twonly.db.g.dart';
|
|||
MediaFilesDao,
|
||||
UserDiscoveryDao,
|
||||
KeyVerificationDao,
|
||||
ShortcutsDao,
|
||||
LabelsDao,
|
||||
ContactGroupsDao,
|
||||
],
|
||||
)
|
||||
class TwonlyDB extends _$TwonlyDB {
|
||||
|
|
@ -93,6 +88,8 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
// Frozen legacy-Drift upgrade chain used only before Rust imports schema
|
||||
// v25. All current application schema migrations are owned by Rust.
|
||||
return MigrationStrategy(
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3362,48 +3362,12 @@ abstract class AppLocalizations {
|
|||
/// **'Registering new account'**
|
||||
String get registeringNewAccount;
|
||||
|
||||
/// No description provided for @createShortcut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create shortcut'**
|
||||
String get createShortcut;
|
||||
|
||||
/// No description provided for @editShortcut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit shortcut'**
|
||||
String get editShortcut;
|
||||
|
||||
/// No description provided for @deleteShortcut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete shortcut'**
|
||||
String get deleteShortcut;
|
||||
|
||||
/// No description provided for @deleteShortcutBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to delete this shortcut?'**
|
||||
String get deleteShortcutBody;
|
||||
|
||||
/// No description provided for @updateShortcut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update shortcut'**
|
||||
String get updateShortcut;
|
||||
|
||||
/// No description provided for @selectEmoji.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Emoji'**
|
||||
String get selectEmoji;
|
||||
|
||||
/// No description provided for @errorEmojiUsedOrInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Emoji already used or invalid'**
|
||||
String get errorEmojiUsedOrInvalid;
|
||||
|
||||
/// No description provided for @subscriptionPledgeSecureTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -4322,65 +4286,125 @@ abstract class AppLocalizations {
|
|||
/// **'Show flame restore warning'**
|
||||
String get settingsShowRestoreFlameTitle;
|
||||
|
||||
/// No description provided for @contactLabelsTitle.
|
||||
/// No description provided for @contactGroupsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Contact Labels'**
|
||||
String get contactLabelsTitle;
|
||||
/// **'Contact Groups'**
|
||||
String get contactGroupsTitle;
|
||||
|
||||
/// No description provided for @contactLabelsSubtitleEmpty.
|
||||
/// No description provided for @contactGroupsSubtitleEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No labels selected'**
|
||||
String get contactLabelsSubtitleEmpty;
|
||||
/// **'No visible contact groups selected'**
|
||||
String get contactGroupsSubtitleEmpty;
|
||||
|
||||
/// No description provided for @contactLabelsMaxLimit.
|
||||
/// No description provided for @createContactGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Maximum 3 labels per contact'**
|
||||
String get contactLabelsMaxLimit;
|
||||
/// **'Create contact group'**
|
||||
String get createContactGroup;
|
||||
|
||||
/// No description provided for @createLabel.
|
||||
/// No description provided for @editContactGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create new label'**
|
||||
String get createLabel;
|
||||
/// **'Edit contact group'**
|
||||
String get editContactGroup;
|
||||
|
||||
/// No description provided for @editLabel.
|
||||
/// No description provided for @deleteContactGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit label'**
|
||||
String get editLabel;
|
||||
/// **'Delete contact group'**
|
||||
String get deleteContactGroup;
|
||||
|
||||
/// No description provided for @deleteLabel.
|
||||
/// No description provided for @deleteContactGroupConfirmation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete label'**
|
||||
String get deleteLabel;
|
||||
/// **'Are you sure you want to delete this contact group? It will be removed from all contacts and groups.'**
|
||||
String get deleteContactGroupConfirmation;
|
||||
|
||||
/// No description provided for @deleteLabelConfirmation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to delete this label? It will be removed from all contacts.'**
|
||||
String get deleteLabelConfirmation;
|
||||
|
||||
/// No description provided for @labelNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label name'**
|
||||
String get labelNameHint;
|
||||
|
||||
/// No description provided for @labelTextColor.
|
||||
/// No description provided for @contactGroupTextColor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Text color'**
|
||||
String get labelTextColor;
|
||||
String get contactGroupTextColor;
|
||||
|
||||
/// No description provided for @labelBackgroundColor.
|
||||
/// No description provided for @contactGroupBackgroundColor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Background color'**
|
||||
String get labelBackgroundColor;
|
||||
String get contactGroupBackgroundColor;
|
||||
|
||||
/// No description provided for @contactGroupNoBackground.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No background'**
|
||||
String get contactGroupNoBackground;
|
||||
|
||||
/// No description provided for @contactGroupFeatures.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Features'**
|
||||
String get contactGroupFeatures;
|
||||
|
||||
/// No description provided for @contactGroupShowAsLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show as visible label'**
|
||||
String get contactGroupShowAsLabel;
|
||||
|
||||
/// No description provided for @contactGroupShowAsLabelSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display this contact group next to its contacts.'**
|
||||
String get contactGroupShowAsLabelSubtitle;
|
||||
|
||||
/// No description provided for @contactGroupShowAsShortcut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show as shortcut'**
|
||||
String get contactGroupShowAsShortcut;
|
||||
|
||||
/// No description provided for @contactGroupShowAsShortcutSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use its emoji to select all members when sharing.'**
|
||||
String get contactGroupShowAsShortcutSubtitle;
|
||||
|
||||
/// No description provided for @contactGroupShortcutNeedsEmoji.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select an emoji before enabling the shortcut.'**
|
||||
String get contactGroupShortcutNeedsEmoji;
|
||||
|
||||
/// No description provided for @contactGroupMembers.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Members'**
|
||||
String get contactGroupMembers;
|
||||
|
||||
/// No description provided for @contactGroupLabelFeature.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visible label'**
|
||||
String get contactGroupLabelFeature;
|
||||
|
||||
/// No description provided for @contactGroupShortcutFeature.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shortcut'**
|
||||
String get contactGroupShortcutFeature;
|
||||
|
||||
/// No description provided for @contactGroupSettings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit contact group'**
|
||||
String get contactGroupSettings;
|
||||
|
||||
/// No description provided for @save.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get save;
|
||||
|
||||
/// No description provided for @customColor.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1936,29 +1936,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get registeringNewAccount => 'Neues Konto wird registriert';
|
||||
|
||||
@override
|
||||
String get createShortcut => 'Shortcut erstellen';
|
||||
|
||||
@override
|
||||
String get editShortcut => 'Shortcut bearbeiten';
|
||||
|
||||
@override
|
||||
String get deleteShortcut => 'Shortcut löschen';
|
||||
|
||||
@override
|
||||
String get deleteShortcutBody =>
|
||||
'Bist du sicher, dass du diesen Shortcut löschen möchtest?';
|
||||
|
||||
@override
|
||||
String get updateShortcut => 'Shortcut aktualisieren';
|
||||
|
||||
@override
|
||||
String get selectEmoji => 'Emoji auswählen';
|
||||
|
||||
@override
|
||||
String get errorEmojiUsedOrInvalid =>
|
||||
'Emoji wird bereits verwendet oder ist ungültig';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
||||
|
||||
|
|
@ -2506,35 +2486,69 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
'Hinweis zur Flammen-Wiederherstellung anzeigen';
|
||||
|
||||
@override
|
||||
String get contactLabelsTitle => 'Kontaktlabels';
|
||||
String get contactGroupsTitle => 'Kontaktgruppen';
|
||||
|
||||
@override
|
||||
String get contactLabelsSubtitleEmpty => 'Keine Labels ausgewählt';
|
||||
String get contactGroupsSubtitleEmpty =>
|
||||
'Keine sichtbaren Kontaktgruppen ausgewählt';
|
||||
|
||||
@override
|
||||
String get contactLabelsMaxLimit => 'Maximal 3 Labels pro Kontakt';
|
||||
String get createContactGroup => 'Kontaktgruppe erstellen';
|
||||
|
||||
@override
|
||||
String get createLabel => 'Neues Label erstellen';
|
||||
String get editContactGroup => 'Kontaktgruppe anpassen';
|
||||
|
||||
@override
|
||||
String get editLabel => 'Label bearbeiten';
|
||||
String get deleteContactGroup => 'Kontaktgruppe löschen';
|
||||
|
||||
@override
|
||||
String get deleteLabel => 'Label löschen';
|
||||
String get deleteContactGroupConfirmation =>
|
||||
'Möchtest du diese Kontaktgruppe wirklich löschen? Sie wird von allen Kontakten und Gruppen entfernt.';
|
||||
|
||||
@override
|
||||
String get deleteLabelConfirmation =>
|
||||
'Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.';
|
||||
String get contactGroupTextColor => 'Textfarbe';
|
||||
|
||||
@override
|
||||
String get labelNameHint => 'Label-Name';
|
||||
String get contactGroupBackgroundColor => 'Hintergrundfarbe';
|
||||
|
||||
@override
|
||||
String get labelTextColor => 'Textfarbe';
|
||||
String get contactGroupNoBackground => 'Kein Hintergrund';
|
||||
|
||||
@override
|
||||
String get labelBackgroundColor => 'Hintergrundfarbe';
|
||||
String get contactGroupFeatures => 'Funktionen';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsLabel => 'Als sichtbares Label anzeigen';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsLabelSubtitle =>
|
||||
'Zeigt diese Kontaktgruppe neben den zugehörigen Kontakten an.';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsShortcut => 'Als Shortcut anzeigen';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsShortcutSubtitle =>
|
||||
'Wählt beim Teilen über das Emoji alle Mitglieder aus.';
|
||||
|
||||
@override
|
||||
String get contactGroupShortcutNeedsEmoji =>
|
||||
'Wähle ein Emoji aus, bevor du den Shortcut aktivierst.';
|
||||
|
||||
@override
|
||||
String get contactGroupMembers => 'Mitglieder';
|
||||
|
||||
@override
|
||||
String get contactGroupLabelFeature => 'Sichtbares Label';
|
||||
|
||||
@override
|
||||
String get contactGroupShortcutFeature => 'Shortcut';
|
||||
|
||||
@override
|
||||
String get contactGroupSettings => 'Kontaktgruppe anpassen';
|
||||
|
||||
@override
|
||||
String get save => 'Speichern';
|
||||
|
||||
@override
|
||||
String get customColor => 'Eigene Farbe';
|
||||
|
|
|
|||
|
|
@ -1922,28 +1922,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get registeringNewAccount => 'Registering new account';
|
||||
|
||||
@override
|
||||
String get createShortcut => 'Create shortcut';
|
||||
|
||||
@override
|
||||
String get editShortcut => 'Edit shortcut';
|
||||
|
||||
@override
|
||||
String get deleteShortcut => 'Delete shortcut';
|
||||
|
||||
@override
|
||||
String get deleteShortcutBody =>
|
||||
'Are you sure you want to delete this shortcut?';
|
||||
|
||||
@override
|
||||
String get updateShortcut => 'Update shortcut';
|
||||
|
||||
@override
|
||||
String get selectEmoji => 'Select Emoji';
|
||||
|
||||
@override
|
||||
String get errorEmojiUsedOrInvalid => 'Emoji already used or invalid';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeSecureTitle => 'Secure by Design';
|
||||
|
||||
|
|
@ -2481,35 +2462,68 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get settingsShowRestoreFlameTitle => 'Show flame restore warning';
|
||||
|
||||
@override
|
||||
String get contactLabelsTitle => 'Contact Labels';
|
||||
String get contactGroupsTitle => 'Contact Groups';
|
||||
|
||||
@override
|
||||
String get contactLabelsSubtitleEmpty => 'No labels selected';
|
||||
String get contactGroupsSubtitleEmpty => 'No visible contact groups selected';
|
||||
|
||||
@override
|
||||
String get contactLabelsMaxLimit => 'Maximum 3 labels per contact';
|
||||
String get createContactGroup => 'Create contact group';
|
||||
|
||||
@override
|
||||
String get createLabel => 'Create new label';
|
||||
String get editContactGroup => 'Edit contact group';
|
||||
|
||||
@override
|
||||
String get editLabel => 'Edit label';
|
||||
String get deleteContactGroup => 'Delete contact group';
|
||||
|
||||
@override
|
||||
String get deleteLabel => 'Delete label';
|
||||
String get deleteContactGroupConfirmation =>
|
||||
'Are you sure you want to delete this contact group? It will be removed from all contacts and groups.';
|
||||
|
||||
@override
|
||||
String get deleteLabelConfirmation =>
|
||||
'Are you sure you want to delete this label? It will be removed from all contacts.';
|
||||
String get contactGroupTextColor => 'Text color';
|
||||
|
||||
@override
|
||||
String get labelNameHint => 'Label name';
|
||||
String get contactGroupBackgroundColor => 'Background color';
|
||||
|
||||
@override
|
||||
String get labelTextColor => 'Text color';
|
||||
String get contactGroupNoBackground => 'No background';
|
||||
|
||||
@override
|
||||
String get labelBackgroundColor => 'Background color';
|
||||
String get contactGroupFeatures => 'Features';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsLabel => 'Show as visible label';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsLabelSubtitle =>
|
||||
'Display this contact group next to its contacts.';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsShortcut => 'Show as shortcut';
|
||||
|
||||
@override
|
||||
String get contactGroupShowAsShortcutSubtitle =>
|
||||
'Use its emoji to select all members when sharing.';
|
||||
|
||||
@override
|
||||
String get contactGroupShortcutNeedsEmoji =>
|
||||
'Select an emoji before enabling the shortcut.';
|
||||
|
||||
@override
|
||||
String get contactGroupMembers => 'Members';
|
||||
|
||||
@override
|
||||
String get contactGroupLabelFeature => 'Visible label';
|
||||
|
||||
@override
|
||||
String get contactGroupShortcutFeature => 'Shortcut';
|
||||
|
||||
@override
|
||||
String get contactGroupSettings => 'Edit contact group';
|
||||
|
||||
@override
|
||||
String get save => 'Save';
|
||||
|
||||
@override
|
||||
String get customColor => 'Custom Color';
|
||||
|
|
|
|||
|
|
@ -687,13 +687,7 @@
|
|||
"registerNewAccount": "Neues Konto registrieren",
|
||||
"tryRestoreAgain": "Wiederherstellung erneut versuchen",
|
||||
"registeringNewAccount": "Neues Konto wird registriert",
|
||||
"createShortcut": "Shortcut erstellen",
|
||||
"editShortcut": "Shortcut bearbeiten",
|
||||
"deleteShortcut": "Shortcut löschen",
|
||||
"deleteShortcutBody": "Bist du sicher, dass du diesen Shortcut löschen möchtest?",
|
||||
"updateShortcut": "Shortcut aktualisieren",
|
||||
"selectEmoji": "Emoji auswählen",
|
||||
"errorEmojiUsedOrInvalid": "Emoji wird bereits verwendet oder ist ungültig",
|
||||
"subscriptionPledgeSecureTitle": "Secure by Design",
|
||||
"subscriptionPledgeSecureDesc": "Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.",
|
||||
"subscriptionPledgeNoAdsTitle": "Keine Werbung oder Datenverkauf",
|
||||
|
|
@ -937,16 +931,26 @@
|
|||
}
|
||||
},
|
||||
"settingsShowRestoreFlameTitle": "Hinweis zur Flammen-Wiederherstellung anzeigen",
|
||||
"contactLabelsTitle": "Kontaktlabels",
|
||||
"contactLabelsSubtitleEmpty": "Keine Labels ausgewählt",
|
||||
"contactLabelsMaxLimit": "Maximal 3 Labels pro Kontakt",
|
||||
"createLabel": "Neues Label erstellen",
|
||||
"editLabel": "Label bearbeiten",
|
||||
"deleteLabel": "Label löschen",
|
||||
"deleteLabelConfirmation": "Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.",
|
||||
"labelNameHint": "Label-Name",
|
||||
"labelTextColor": "Textfarbe",
|
||||
"labelBackgroundColor": "Hintergrundfarbe",
|
||||
"contactGroupsTitle": "Kontaktgruppen",
|
||||
"contactGroupsSubtitleEmpty": "Keine sichtbaren Kontaktgruppen ausgewählt",
|
||||
"createContactGroup": "Kontaktgruppe erstellen",
|
||||
"editContactGroup": "Kontaktgruppe anpassen",
|
||||
"deleteContactGroup": "Kontaktgruppe löschen",
|
||||
"deleteContactGroupConfirmation": "Möchtest du diese Kontaktgruppe wirklich löschen? Sie wird von allen Kontakten und Gruppen entfernt.",
|
||||
"contactGroupTextColor": "Textfarbe",
|
||||
"contactGroupBackgroundColor": "Hintergrundfarbe",
|
||||
"contactGroupNoBackground": "Kein Hintergrund",
|
||||
"contactGroupFeatures": "Funktionen",
|
||||
"contactGroupShowAsLabel": "Als sichtbares Label anzeigen",
|
||||
"contactGroupShowAsLabelSubtitle": "Zeigt diese Kontaktgruppe neben den zugehörigen Kontakten an.",
|
||||
"contactGroupShowAsShortcut": "Als Shortcut anzeigen",
|
||||
"contactGroupShowAsShortcutSubtitle": "Wählt beim Teilen über das Emoji alle Mitglieder aus.",
|
||||
"contactGroupShortcutNeedsEmoji": "Wähle ein Emoji aus, bevor du den Shortcut aktivierst.",
|
||||
"contactGroupMembers": "Mitglieder",
|
||||
"contactGroupLabelFeature": "Sichtbares Label",
|
||||
"contactGroupShortcutFeature": "Shortcut",
|
||||
"contactGroupSettings": "Kontaktgruppe anpassen",
|
||||
"save": "Speichern",
|
||||
"customColor": "Eigene Farbe",
|
||||
"hue": "Farbton",
|
||||
"saturation": "Sättigung",
|
||||
|
|
|
|||
|
|
@ -697,13 +697,7 @@
|
|||
"registerNewAccount": "Register New Account",
|
||||
"tryRestoreAgain": "Try Restore Again",
|
||||
"registeringNewAccount": "Registering new account",
|
||||
"createShortcut": "Create shortcut",
|
||||
"editShortcut": "Edit shortcut",
|
||||
"deleteShortcut": "Delete shortcut",
|
||||
"deleteShortcutBody": "Are you sure you want to delete this shortcut?",
|
||||
"updateShortcut": "Update shortcut",
|
||||
"selectEmoji": "Select Emoji",
|
||||
"errorEmojiUsedOrInvalid": "Emoji already used or invalid",
|
||||
"subscriptionPledgeSecureTitle": "Secure by Design",
|
||||
"subscriptionPledgeSecureDesc": "Your messages and shared moments are fully end-to-end encrypted.",
|
||||
"subscriptionPledgeNoAdsTitle": "No Ads or Data selling",
|
||||
|
|
@ -947,16 +941,26 @@
|
|||
}
|
||||
},
|
||||
"settingsShowRestoreFlameTitle": "Show flame restore warning",
|
||||
"contactLabelsTitle": "Contact Labels",
|
||||
"contactLabelsSubtitleEmpty": "No labels selected",
|
||||
"contactLabelsMaxLimit": "Maximum 3 labels per contact",
|
||||
"createLabel": "Create new label",
|
||||
"editLabel": "Edit label",
|
||||
"deleteLabel": "Delete label",
|
||||
"deleteLabelConfirmation": "Are you sure you want to delete this label? It will be removed from all contacts.",
|
||||
"labelNameHint": "Label name",
|
||||
"labelTextColor": "Text color",
|
||||
"labelBackgroundColor": "Background color",
|
||||
"contactGroupsTitle": "Contact Groups",
|
||||
"contactGroupsSubtitleEmpty": "No visible contact groups selected",
|
||||
"createContactGroup": "Create contact group",
|
||||
"editContactGroup": "Edit contact group",
|
||||
"deleteContactGroup": "Delete contact group",
|
||||
"deleteContactGroupConfirmation": "Are you sure you want to delete this contact group? It will be removed from all contacts and groups.",
|
||||
"contactGroupTextColor": "Text color",
|
||||
"contactGroupBackgroundColor": "Background color",
|
||||
"contactGroupNoBackground": "No background",
|
||||
"contactGroupFeatures": "Features",
|
||||
"contactGroupShowAsLabel": "Show as visible label",
|
||||
"contactGroupShowAsLabelSubtitle": "Display this contact group next to its contacts.",
|
||||
"contactGroupShowAsShortcut": "Show as shortcut",
|
||||
"contactGroupShowAsShortcutSubtitle": "Use its emoji to select all members when sharing.",
|
||||
"contactGroupShortcutNeedsEmoji": "Select an emoji before enabling the shortcut.",
|
||||
"contactGroupMembers": "Members",
|
||||
"contactGroupLabelFeature": "Visible label",
|
||||
"contactGroupShortcutFeature": "Shortcut",
|
||||
"contactGroupSettings": "Edit contact group",
|
||||
"save": "Save",
|
||||
"customColor": "Custom Color",
|
||||
"hue": "Hue",
|
||||
"saturation": "Saturation",
|
||||
|
|
|
|||
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:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
|
|
@ -21,80 +23,75 @@ class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hsvColor = HSVColor.fromColor(widget.initialColor);
|
||||
final initialHsvColor = HSVColor.fromColor(widget.initialColor);
|
||||
final hasVisibleInitialColor =
|
||||
widget.initialColor.toARGB32() & 0xFF000000 != 0;
|
||||
_hsvColor =
|
||||
(hasVisibleInitialColor
|
||||
? initialHsvColor
|
||||
: initialHsvColor.withValue(1))
|
||||
.withAlpha(1);
|
||||
}
|
||||
|
||||
void _selectColor(Offset position, Size size) {
|
||||
const indicatorRadius = 12.0;
|
||||
final center = size.center(Offset.zero);
|
||||
final wheelRadius = size.shortestSide / 2 - indicatorRadius;
|
||||
final offset = position - center;
|
||||
final distance = offset.distance;
|
||||
final saturation = (distance / wheelRadius).clamp(0.0, 1.0);
|
||||
final hue = distance < 1
|
||||
? _hsvColor.hue
|
||||
: (math.atan2(offset.dy, offset.dx) * 180 / math.pi + 360) % 360;
|
||||
|
||||
setState(() {
|
||||
_hsvColor = HSVColor.fromAHSV(
|
||||
_hsvColor.alpha,
|
||||
hue,
|
||||
saturation,
|
||||
_hsvColor.value,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentColor = _hsvColor.toColor();
|
||||
final availableWidth = MediaQuery.sizeOf(context).width - 96;
|
||||
final wheelSize = math.max<double>(
|
||||
120,
|
||||
math.min<double>(240, availableWidth),
|
||||
);
|
||||
final colorWheelSize = Size.square(wheelSize);
|
||||
|
||||
return AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
title: Text(context.lang.customColor),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Color preview box
|
||||
Center(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: currentColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
child: SizedBox.square(
|
||||
dimension: wheelSize,
|
||||
child: Semantics(
|
||||
label: context.lang.customColor,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (details) =>
|
||||
_selectColor(details.localPosition, colorWheelSize),
|
||||
onPanUpdate: (details) =>
|
||||
_selectColor(details.localPosition, colorWheelSize),
|
||||
onTapUp: (details) =>
|
||||
_selectColor(details.localPosition, colorWheelSize),
|
||||
child: CustomPaint(
|
||||
painter: _ColorWheelPainter(hsvColor: _hsvColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Hue Slider
|
||||
Text(
|
||||
context.lang.hue,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Slider(
|
||||
value: _hsvColor.hue,
|
||||
max: 360,
|
||||
activeColor: HSVColor.fromAHSV(1, _hsvColor.hue, 1, 1).toColor(),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_hsvColor = _hsvColor.withHue(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
// Saturation Slider
|
||||
Text(
|
||||
context.lang.saturation,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Slider(
|
||||
value: _hsvColor.saturation,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_hsvColor = _hsvColor.withSaturation(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
// Brightness / Value Slider
|
||||
Text(
|
||||
context.lang.brightness,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Slider(
|
||||
value: _hsvColor.value,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_hsvColor = _hsvColor.withValue(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -123,3 +120,83 @@ class _CustomColorPickerDialogState extends State<CustomColorPickerDialog> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorWheelPainter extends CustomPainter {
|
||||
const _ColorWheelPainter({required this.hsvColor});
|
||||
|
||||
final HSVColor hsvColor;
|
||||
|
||||
static const _indicatorRadius = 12.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = size.shortestSide / 2 - _indicatorRadius;
|
||||
final wheelRect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(Path()..addOval(wheelRect))
|
||||
..drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..shader = const SweepGradient(
|
||||
colors: [
|
||||
Color(0xFFFF0000),
|
||||
Color(0xFFFFFF00),
|
||||
Color(0xFF00FF00),
|
||||
Color(0xFF00FFFF),
|
||||
Color(0xFF0000FF),
|
||||
Color(0xFFFF00FF),
|
||||
Color(0xFFFF0000),
|
||||
],
|
||||
).createShader(wheelRect),
|
||||
)
|
||||
..drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..shader = const RadialGradient(
|
||||
colors: [Colors.white, Colors.transparent],
|
||||
).createShader(wheelRect),
|
||||
);
|
||||
|
||||
if (hsvColor.value < 1) {
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()..color = Colors.black.withValues(alpha: 1 - hsvColor.value),
|
||||
);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
final angle = hsvColor.hue * math.pi / 180;
|
||||
final indicatorCenter =
|
||||
center +
|
||||
Offset(math.cos(angle), math.sin(angle)) * hsvColor.saturation * radius;
|
||||
final selectedColor = hsvColor.withAlpha(1).toColor();
|
||||
|
||||
canvas
|
||||
..drawCircle(
|
||||
indicatorCenter,
|
||||
_indicatorRadius,
|
||||
Paint()..color = Colors.black.withValues(alpha: 0.3),
|
||||
)
|
||||
..drawCircle(
|
||||
indicatorCenter,
|
||||
_indicatorRadius - 2,
|
||||
Paint()..color = Colors.white,
|
||||
)
|
||||
..drawCircle(
|
||||
indicatorCenter,
|
||||
_indicatorRadius - 5,
|
||||
Paint()..color = selectedColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ColorWheelPainter oldDelegate) =>
|
||||
oldDelegate.hsvColor != hsvColor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
const MediaViewSizingHelper.cameraEditor({
|
||||
required this.child,
|
||||
required this.bottomNavigation,
|
||||
super.key,
|
||||
}) : requiredHeight = 59,
|
||||
additionalPadding = null;
|
||||
|
||||
final double? requiredHeight;
|
||||
final double? additionalPadding;
|
||||
final Widget? bottomNavigation;
|
||||
|
|
@ -21,69 +28,49 @@ class MediaViewSizingHelper extends StatefulWidget {
|
|||
class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var needToDownSizeImage = false;
|
||||
|
||||
// Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final safeAreaPadding = MediaQuery.paddingOf(context);
|
||||
|
||||
// Calculate the available width and height
|
||||
final availableWidth = screenSize.width;
|
||||
final availableHeight =
|
||||
screenSize.height -
|
||||
safeAreaPadding.top -
|
||||
safeAreaPadding.bottom -
|
||||
(widget.additionalPadding ?? 0);
|
||||
|
||||
final aspectRatioWidth = availableWidth;
|
||||
final aspectRatioHeight = (aspectRatioWidth * 16) / 9;
|
||||
if (aspectRatioHeight > availableHeight) {
|
||||
needToDownSizeImage = true;
|
||||
}
|
||||
if (widget.requiredHeight != null) {
|
||||
if (aspectRatioHeight < availableHeight) {
|
||||
if ((screenSize.height - widget.requiredHeight!) < aspectRatioHeight) {
|
||||
needToDownSizeImage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget imageChild = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
// height: availableHeight,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 9 / 16,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget bottomNavigation = Container();
|
||||
|
||||
if (widget.bottomNavigation != null) {
|
||||
if (needToDownSizeImage) {
|
||||
imageChild = Expanded(child: imageChild);
|
||||
bottomNavigation = SizedBox(
|
||||
height: widget.requiredHeight,
|
||||
child: widget.bottomNavigation,
|
||||
);
|
||||
} else {
|
||||
bottomNavigation = Expanded(child: widget.bottomNavigation!);
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: availableHeight,
|
||||
),
|
||||
child: Column(
|
||||
children: [imageChild, bottomNavigation],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
final availableHeight =
|
||||
constraints.maxHeight - (widget.additionalPadding ?? 0);
|
||||
final aspectRatioHeight = (availableWidth * 16) / 9;
|
||||
final bottomNavigationHeight = widget.requiredHeight ?? 0;
|
||||
final needToDownSizeImage =
|
||||
aspectRatioHeight + bottomNavigationHeight > availableHeight;
|
||||
|
||||
Widget imageChild = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 9 / 16,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget bottomNavigation = const SizedBox.shrink();
|
||||
|
||||
if (widget.bottomNavigation != null) {
|
||||
if (needToDownSizeImage) {
|
||||
imageChild = Expanded(child: imageChild);
|
||||
bottomNavigation = SizedBox(
|
||||
height: widget.requiredHeight,
|
||||
child: widget.bottomNavigation,
|
||||
);
|
||||
} else {
|
||||
bottomNavigation = Expanded(child: widget.bottomNavigation!);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: availableHeight),
|
||||
child: Column(
|
||||
children: [imageChild, bottomNavigation],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
const defaultPrimaryColor = Color(0xFF57CC99);
|
||||
const defaultPrimaryColor = Color(0xFF32be80);
|
||||
|
||||
ThemeData getLightTheme([Color primary = defaultPrimaryColor]) {
|
||||
final base = ThemeData(
|
||||
|
|
|
|||
|
|
@ -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 Positioned.fill(
|
||||
child: MediaViewSizingHelper(
|
||||
requiredHeight: 0,
|
||||
additionalPadding: 59,
|
||||
bottomNavigation: Container(),
|
||||
child: MediaViewSizingHelper.cameraEditor(
|
||||
bottomNavigation: const SizedBox.shrink(),
|
||||
child: Stack(
|
||||
children: [
|
||||
Screenshot(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -35,7 +34,6 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/send_to
|
|||
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_budget.dart';
|
||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_time.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
|
||||
import 'package:twonly/src/visual/views/home.view.dart';
|
||||
|
||||
class SelectedCameraDetails {
|
||||
|
|
@ -764,10 +762,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
return StreamBuilder(
|
||||
stream: userService.onUserUpdated,
|
||||
builder: (context, asyncSnapshot) {
|
||||
return MediaViewSizingHelper(
|
||||
requiredHeight: 0,
|
||||
additionalPadding: 59,
|
||||
bottomNavigation: Container(),
|
||||
return MediaViewSizingHelper.cameraEditor(
|
||||
bottomNavigation: const SizedBox.shrink(),
|
||||
child: GestureDetector(
|
||||
onPanStart: (details) async {
|
||||
setState(() {
|
||||
|
|
@ -891,19 +887,6 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
currentTime: _currentTime,
|
||||
maxRecordingTime: _currentMaxRecordingTime,
|
||||
),
|
||||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
|
||||
widget.hideControllers)
|
||||
Positioned(
|
||||
left: 5,
|
||||
top: 10,
|
||||
child: ActionButton(
|
||||
FontAwesomeIcons.xmark,
|
||||
tooltipText: context.lang.close,
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_showSelfieFlash) const CameraSelfieFlash(),
|
||||
CameraScannedOverlay(mainController: mc),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -68,7 +68,14 @@ class MainCameraController {
|
|||
GlobalKey cameraPreviewKey = GlobalKey();
|
||||
|
||||
bool isSelectingFaceFilters = false;
|
||||
bool isSharePreviewIsShown = false;
|
||||
bool _isSharePreviewIsShown = false;
|
||||
bool get isSharePreviewIsShown => _isSharePreviewIsShown;
|
||||
set isSharePreviewIsShown(bool value) {
|
||||
if (_isSharePreviewIsShown == value) return;
|
||||
_isSharePreviewIsShown = value;
|
||||
setState?.call();
|
||||
}
|
||||
|
||||
bool isVideoRecording = false;
|
||||
DateTime? timeSharedLinkWasSetWithQr;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,17 +63,19 @@ class CameraSendToViewState extends State<CameraSendToView> {
|
|||
isVisible: true,
|
||||
),
|
||||
),
|
||||
// Always-visible back button so the user can exit regardless of
|
||||
// camera state (e.g. during init or after init failure).
|
||||
Positioned(
|
||||
left: 5,
|
||||
top: MediaQuery.paddingOf(context).top + 10,
|
||||
child: ActionButton(
|
||||
FontAwesomeIcons.xmark,
|
||||
tooltipText: context.lang.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
// Keep this route's close button available during camera startup and
|
||||
// failures, but hide it beneath the transparent editor, which has
|
||||
// its own close action.
|
||||
if (!_mainCameraController.isSharePreviewIsShown)
|
||||
Positioned(
|
||||
left: 5,
|
||||
top: MediaQuery.paddingOf(context).top + 10,
|
||||
child: ActionButton(
|
||||
FontAwesomeIcons.xmark,
|
||||
tooltipText: context.lang.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import 'package:twonly/src/visual/elements/headline.element.dart';
|
|||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/background.layer.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ShortcutRowComp(
|
||||
ContactGroupShortcutRow(
|
||||
selectedGroupIds: widget.selectedGroupIds,
|
||||
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
child: MediaViewSizingHelper(
|
||||
requiredHeight: 59,
|
||||
child: MediaViewSizingHelper.cameraEditor(
|
||||
bottomNavigation: EditorBottomBar(
|
||||
mediaService: mediaService,
|
||||
sendToGroup: widget.sendToGroup,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ class _ChatListViewState extends State<ChatListView>
|
|||
StreamSubscription<List<Message>>? _latestMessagesSub;
|
||||
StreamSubscription<List<MediaFile>>? _chatListMediaSub;
|
||||
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
|
||||
StreamSubscription<List<(int, Label)>>? _contactLabelsSub;
|
||||
StreamSubscription<List<(int, ContactGroup)>>? _contactGroupsSub;
|
||||
StreamSubscription<List<(String, ContactGroup)>>? _chatContactGroupsSub;
|
||||
Timer? _typingUpdateTimer;
|
||||
final Set<String> _precachedMediaIds = {};
|
||||
List<Group> _groupsNotPinned = [];
|
||||
|
|
@ -58,7 +59,8 @@ class _ChatListViewState extends State<ChatListView>
|
|||
Map<String, Message> _lastMessageByGroup = {};
|
||||
Map<String, MediaFile> _chatListMediaById = {};
|
||||
Map<String, VerificationStatus> _verificationByGroup = {};
|
||||
Map<int, List<Label>> _labelsByContact = {};
|
||||
Map<int, List<ContactGroup>> _contactGroupsByUser = {};
|
||||
Map<String, List<ContactGroup>> _contactGroupsByGroup = {};
|
||||
|
||||
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
||||
bool _loading = true;
|
||||
|
|
@ -207,16 +209,39 @@ class _ChatListViewState extends State<ChatListView>
|
|||
if (!mounted) return;
|
||||
setState(() => _verificationByGroup = statuses);
|
||||
});
|
||||
_contactLabelsSub = twonlyDB.labelsDao.watchAllContactLabels().listen((
|
||||
rows,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
final labels = <int, List<Label>>{};
|
||||
for (final row in rows) {
|
||||
labels.putIfAbsent(row.$1, () => []).add(row.$2);
|
||||
}
|
||||
setState(() => _labelsByContact = labels);
|
||||
});
|
||||
_contactGroupsSub = twonlyDB.contactGroupsDao
|
||||
.watchAllVisibleUserGroups()
|
||||
.listen((
|
||||
rows,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
final contactGroups = <int, List<ContactGroup>>{};
|
||||
for (final row in rows) {
|
||||
contactGroups.putIfAbsent(row.$1, () => []).add(row.$2);
|
||||
}
|
||||
setState(() => _contactGroupsByUser = contactGroups);
|
||||
});
|
||||
_chatContactGroupsSub = twonlyDB.contactGroupsDao
|
||||
.watchAllVisibleChatGroups()
|
||||
.listen((rows) {
|
||||
if (!mounted) return;
|
||||
final contactGroups = <String, List<ContactGroup>>{};
|
||||
for (final row in rows) {
|
||||
contactGroups.putIfAbsent(row.$1, () => []).add(row.$2);
|
||||
}
|
||||
setState(() => _contactGroupsByGroup = contactGroups);
|
||||
});
|
||||
}
|
||||
|
||||
/// Labels of a chat: those of the contact for direct chats, those of the
|
||||
/// group itself otherwise.
|
||||
List<ContactGroup> _contactGroupsFor(Group group) {
|
||||
if (!group.isDirectChat) {
|
||||
return _contactGroupsByGroup[group.groupId] ?? const [];
|
||||
}
|
||||
final contacts = _contactsByGroup[group.groupId];
|
||||
if (contacts == null || contacts.isEmpty) return const [];
|
||||
return _contactGroupsByUser[contacts.first.userId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -238,7 +263,8 @@ class _ChatListViewState extends State<ChatListView>
|
|||
_latestMessagesSub?.cancel();
|
||||
_chatListMediaSub?.cancel();
|
||||
_verificationSub?.cancel();
|
||||
_contactLabelsSub?.cancel();
|
||||
_contactGroupsSub?.cancel();
|
||||
_chatContactGroupsSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -419,13 +445,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
mediaFiles: _mediaForGroup(group.groupId),
|
||||
useSharedSummary: true,
|
||||
verificationStatus: _verificationByGroup[group.groupId],
|
||||
contactLabels:
|
||||
_contactsByGroup[group.groupId]?.isNotEmpty == true
|
||||
? _labelsByContact[_contactsByGroup[group.groupId]!
|
||||
.first
|
||||
.userId] ??
|
||||
const []
|
||||
: const [],
|
||||
contactGroups: _contactGroupsFor(group),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -454,13 +474,7 @@ class _ChatListViewState extends State<ChatListView>
|
|||
mediaFiles: _mediaForGroup(group.groupId),
|
||||
useSharedSummary: true,
|
||||
verificationStatus: _verificationByGroup[group.groupId],
|
||||
contactLabels:
|
||||
_contactsByGroup[group.groupId]?.isNotEmpty == true
|
||||
? _labelsByContact[_contactsByGroup[group.groupId]!
|
||||
.first
|
||||
.userId] ??
|
||||
const []
|
||||
: const [],
|
||||
contactGroups: _contactGroupsFor(group),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
|||
import 'package:twonly/src/services/mediafiles/media_download_policy.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
||||
|
|
@ -32,7 +32,7 @@ class GroupListItemComp extends StatefulWidget {
|
|||
this.mediaFiles,
|
||||
this.useSharedSummary = false,
|
||||
this.verificationStatus,
|
||||
this.contactLabels = const [],
|
||||
this.contactGroups = const [],
|
||||
super.key,
|
||||
});
|
||||
final Group group;
|
||||
|
|
@ -44,7 +44,7 @@ class GroupListItemComp extends StatefulWidget {
|
|||
final Map<String, MediaFile>? mediaFiles;
|
||||
final bool useSharedSummary;
|
||||
final VerificationStatus? verificationStatus;
|
||||
final List<Label> contactLabels;
|
||||
final List<ContactGroup> contactGroups;
|
||||
|
||||
@override
|
||||
State<GroupListItemComp> createState() => _UserListItem();
|
||||
|
|
@ -324,38 +324,49 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
return GroupContextMenu(
|
||||
group: widget.group,
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.group.groupName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
VerificationBadgeComp(
|
||||
group: widget.group,
|
||||
verificationStatus: widget.verificationStatus,
|
||||
useProvidedStatus: widget.useSharedSummary,
|
||||
showOnlyIfVerified: true,
|
||||
clickable: false,
|
||||
size: 12,
|
||||
),
|
||||
if (widget.group.isDirectChat && _directContact != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: ContactLabels(
|
||||
contactId: _directContact!.userId,
|
||||
labels: widget.contactLabels,
|
||||
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: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: nameMaxWidth),
|
||||
child: Text(
|
||||
widget.group.groupName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(width: 3),
|
||||
VerificationBadgeComp(
|
||||
group: widget.group,
|
||||
verificationStatus: widget.verificationStatus,
|
||||
useProvidedStatus: widget.useSharedSummary,
|
||||
showOnlyIfVerified: true,
|
||||
clickable: false,
|
||||
size: 12,
|
||||
),
|
||||
if (showBadges) ...[
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ContactGroupBadges(
|
||||
userId: _directContact?.userId,
|
||||
groupId: widget.group.isDirectChat
|
||||
? null
|
||||
: widget.group.groupId,
|
||||
contactGroups: widget.contactGroups,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
subtitle: _receiverDeletedAccount
|
||||
? 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/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/themes/colors.dart';
|
||||
|
|
@ -626,9 +626,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
FlameCounterWidget(group: group),
|
||||
],
|
||||
),
|
||||
if (group.isDirectChat && _groupContacts.isNotEmpty)
|
||||
ContactLabels(
|
||||
contactId: _groupContacts.first.userId,
|
||||
if (!group.isDirectChat)
|
||||
ContactGroupBadges(groupId: group.groupId)
|
||||
else if (_groupContacts.isNotEmpty)
|
||||
ContactGroupBadges(
|
||||
userId: _groupContacts.first.userId,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -662,7 +662,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
});
|
||||
},
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SafeArea(
|
||||
maintainBottomViewPadding: true,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
|
@ -738,6 +740,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
if (showSendTextMessageInput)
|
||||
MediaViewerMessageInput(
|
||||
controller: textMessageController,
|
||||
safeAreaBottomPadding: MediaQuery.viewPaddingOf(
|
||||
context,
|
||||
).bottom,
|
||||
onSubmitted: (value) => _sendTextMessage(),
|
||||
onSendPressed: _sendTextMessage,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,19 +7,26 @@ import 'package:twonly/src/visual/elements/my_input.element.dart';
|
|||
class MediaViewerMessageInput extends StatelessWidget {
|
||||
const MediaViewerMessageInput({
|
||||
required this.controller,
|
||||
required this.safeAreaBottomPadding,
|
||||
required this.onSubmitted,
|
||||
required this.onSendPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final double safeAreaBottomPadding;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
final VoidCallback onSendPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardBottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final bottomOffset = keyboardBottomInset > safeAreaBottomPadding
|
||||
? keyboardBottomInset - safeAreaBottomPadding
|
||||
: 0.0;
|
||||
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
bottom: bottomOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
|
|||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/context_menu/group.context_menu.dart';
|
||||
|
|
@ -221,8 +221,8 @@ class _StartNewChatView extends State<StartNewChatView> {
|
|||
return UserContextMenu(
|
||||
key: ValueKey(contact.userId),
|
||||
contact: contact,
|
||||
child: ContactLabelsSubtitleBuilder(
|
||||
contactId: contact.userId,
|
||||
child: ContactGroupsSubtitleBuilder(
|
||||
userId: contact.userId,
|
||||
builder: (context, subtitleWidget) {
|
||||
return ListTile(
|
||||
title: Row(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
|||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
|
|
@ -22,7 +22,7 @@ import 'package:twonly/src/visual/views/contact/contact_components/mutual_groups
|
|||
import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_components/user_discovery_contact_settings.comp.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart';
|
||||
import 'package:twonly/src/visual/views/contact/select_contact_labels.view.dart';
|
||||
import 'package:twonly/src/visual/views/contact/select_contact_groups.view.dart';
|
||||
import 'package:twonly/src/visual/views/groups/group.view.dart';
|
||||
|
||||
class ContactView extends StatefulWidget {
|
||||
|
|
@ -230,16 +230,16 @@ class _ContactViewState extends State<ContactView> {
|
|||
userService.currentUser.userId,
|
||||
),
|
||||
),
|
||||
ContactLabelsSubtitleBuilder(
|
||||
contactId: contact.userId,
|
||||
ContactGroupsSubtitleBuilder(
|
||||
userId: contact.userId,
|
||||
builder: (context, subtitleWidget) {
|
||||
return BetterListTile(
|
||||
icon: FontAwesomeIcons.tag,
|
||||
text: context.lang.contactLabelsTitle,
|
||||
text: context.lang.contactGroupsTitle,
|
||||
subtitle: subtitleWidget,
|
||||
onTap: () {
|
||||
context.navPush(
|
||||
SelectContactLabelsView(contactId: contact.userId),
|
||||
SelectContactGroupsView(userId: contact.userId),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
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/visual/components/alert.dialog.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/select_chat_deletion_time.comp.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact.view.dart';
|
||||
import 'package:twonly/src/visual/views/contact/select_contact_groups.view.dart';
|
||||
import 'package:twonly/src/visual/views/groups/group_create_select_members.view.dart';
|
||||
import 'package:twonly/src/visual/views/groups/group_member.context.dart';
|
||||
import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
|
||||
|
|
@ -212,6 +213,21 @@ class _GroupViewState extends State<GroupView> {
|
|||
groupId: widget.groupId,
|
||||
disabled: !_group!.isGroupAdmin,
|
||||
),
|
||||
ContactGroupsSubtitleBuilder(
|
||||
groupId: widget.groupId,
|
||||
builder: (context, subtitleWidget) {
|
||||
return BetterListTile(
|
||||
icon: FontAwesomeIcons.tag,
|
||||
text: context.lang.contactGroupsTitle,
|
||||
subtitle: subtitleWidget,
|
||||
onTap: () {
|
||||
context.navPush(
|
||||
SelectContactGroupsView(groupId: widget.groupId),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Padding(
|
||||
|
|
@ -254,8 +270,8 @@ class _GroupViewState extends State<GroupView> {
|
|||
group: _group!,
|
||||
contact: member.$1,
|
||||
member: member.$2,
|
||||
child: ContactLabelsSubtitleBuilder(
|
||||
contactId: member.$1.userId,
|
||||
child: ContactGroupsSubtitleBuilder(
|
||||
userId: member.$1.userId,
|
||||
builder: (context, subtitleWidget) {
|
||||
return BetterListTile(
|
||||
padding: const EdgeInsets.only(left: 13),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
|
|||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
|
||||
|
|
@ -126,8 +126,8 @@ class _GroupCreateSelectGroupNameViewState
|
|||
return UserContextMenu(
|
||||
key: ValueKey(user.userId),
|
||||
contact: user,
|
||||
child: ContactLabelsSubtitleBuilder(
|
||||
contactId: user.userId,
|
||||
child: ContactGroupsSubtitleBuilder(
|
||||
userId: user.userId,
|
||||
builder: (context, subtitleWidget) {
|
||||
return ListTile(
|
||||
title: Row(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
|
|||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
import 'package:twonly/src/visual/components/flame_counter.comp.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||
|
|
@ -219,8 +219,8 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
|
|||
return UserContextMenu(
|
||||
key: ValueKey(user.userId),
|
||||
contact: user,
|
||||
child: ContactLabelsSubtitleBuilder(
|
||||
contactId: user.userId,
|
||||
child: ContactGroupsSubtitleBuilder(
|
||||
userId: user.userId,
|
||||
additionalSubtitle:
|
||||
alreadyInGroup.contains(user.userId)
|
||||
? Text(context.lang.alreadyInGroup)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:twonly/src/services/user.service.dart';
|
|||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
|
||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
import 'package:twonly/src/visual/themes/light.dart';
|
||||
import 'package:twonly/src/visual/views/settings/backup/backup_utils.dart';
|
||||
import 'package:twonly/src/visual/views/settings/backup/components/recovery_card.comp.dart';
|
||||
import 'package:twonly/src/visual/views/settings/backup/memories_backup_detail.view.dart';
|
||||
|
|
@ -185,7 +186,10 @@ class _BackupViewState extends State<BackupView> {
|
|||
_backupStatus?.archiveSize,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.check_circle, color: Colors.green),
|
||||
trailing: const Icon(
|
||||
Icons.check_circle,
|
||||
color: defaultPrimaryColor,
|
||||
),
|
||||
),
|
||||
|
||||
// Bilder & Medien
|
||||
|
|
|
|||
|
|
@ -492,7 +492,24 @@ mod tests {
|
|||
crate::database::app::AppDatabase::new(&legacy_path.display().to_string(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
legacy.run_migrations().await.unwrap();
|
||||
for migration in [
|
||||
include_str!("../database/app/migrations/0001_initial.sql"),
|
||||
include_str!("../database/app/migrations/0002_api_outbox.sql"),
|
||||
include_str!("../database/app/migrations/0003_notification_outbox.sql"),
|
||||
include_str!("../database/app/migrations/0004_sealed_sender.sql"),
|
||||
include_str!("../database/app/migrations/0005_direct_media_upload.sql"),
|
||||
include_str!("../database/app/migrations/0006_defer_receipts_missing_bundle.sql"),
|
||||
include_str!("../database/app/migrations/0007_remove_experimental_transport.sql"),
|
||||
include_str!("../database/app/migrations/0008_pending_plaintext.sql"),
|
||||
include_str!("../database/app/migrations/0009_outbox_dispatch.sql"),
|
||||
include_str!("../database/app/migrations/0010_media_trim.sql"),
|
||||
include_str!("../database/app/migrations/0011_outgoing_contact_request.sql"),
|
||||
] {
|
||||
sqlx::raw_sql(migration)
|
||||
.execute(&legacy.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query!(r#"PRAGMA user_version = 25"#)
|
||||
.execute(&legacy.pool)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
*
|
||||
*/
|
||||
|
||||
use super::{AppDatabase, MigrationReport, TableMigrationCount, APPLICATION_TABLES};
|
||||
use super::{
|
||||
AppDatabase, MigrationReport, TableMigrationCount, APPLICATION_TABLES, LEGACY_COPY_TABLES,
|
||||
};
|
||||
use crate::error::{Result, TwonlyError};
|
||||
use sqlx::{Acquire, AssertSqlSafe, Row};
|
||||
use std::collections::BTreeSet;
|
||||
|
|
@ -114,7 +116,7 @@ async fn import(
|
|||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let mut counts = Vec::with_capacity(APPLICATION_TABLES.len());
|
||||
for table in APPLICATION_TABLES {
|
||||
for table in LEGACY_COPY_TABLES {
|
||||
let columns = common_columns(&mut tx, table).await?;
|
||||
if columns.is_empty() {
|
||||
return Err(TwonlyError::Generic(format!(
|
||||
|
|
@ -166,6 +168,7 @@ async fn import(
|
|||
rows: source_count,
|
||||
});
|
||||
}
|
||||
migrate_legacy_contact_groups(&mut tx, &mut counts).await?;
|
||||
copy_sequences(&mut tx).await?;
|
||||
let foreign_key_errors =
|
||||
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM pragma_foreign_key_check"#)
|
||||
|
|
@ -202,6 +205,80 @@ async fn import(
|
|||
})
|
||||
}
|
||||
|
||||
async fn migrate_legacy_contact_groups(
|
||||
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
counts: &mut Vec<TableMigrationCount>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO main.contact_groups (
|
||||
id, name, emoji, text_color, background_color,
|
||||
show_as_shortcut, show_as_label, usage_counter, created_at
|
||||
)
|
||||
SELECT id, name, NULL, text_color, background_color,
|
||||
0, 1, 0, created_at
|
||||
FROM legacy.labels"#,
|
||||
)
|
||||
.execute(&mut **connection)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT OR IGNORE INTO main.contact_group_members
|
||||
(contact_group_id, user_id, group_id)
|
||||
SELECT label_id, contact_id, NULL
|
||||
FROM legacy.contact_labels"#,
|
||||
)
|
||||
.execute(&mut **connection)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT INTO main.contact_groups (
|
||||
id, name, emoji, text_color, background_color,
|
||||
show_as_shortcut, show_as_label, usage_counter, created_at
|
||||
)
|
||||
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + id,
|
||||
emoji, emoji, 4278190080, 0,
|
||||
1, 0, usage_counter,
|
||||
CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)
|
||||
FROM legacy.shortcuts"#,
|
||||
)
|
||||
.execute(&mut **connection)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT OR IGNORE INTO main.contact_group_members
|
||||
(contact_group_id, user_id, group_id)
|
||||
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + sm.shortcut_id,
|
||||
gm.contact_id, NULL
|
||||
FROM legacy.shortcut_members sm
|
||||
INNER JOIN legacy.groups g ON g.group_id = sm.group_id
|
||||
INNER JOIN legacy.group_members gm ON gm.group_id = g.group_id
|
||||
WHERE g.is_direct_chat = 1"#,
|
||||
)
|
||||
.execute(&mut **connection)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT OR IGNORE INTO main.contact_group_members
|
||||
(contact_group_id, user_id, group_id)
|
||||
SELECT COALESCE((SELECT MAX(id) FROM legacy.labels), 0) + sm.shortcut_id,
|
||||
NULL, sm.group_id
|
||||
FROM legacy.shortcut_members sm
|
||||
INNER JOIN legacy.groups g ON g.group_id = sm.group_id
|
||||
WHERE g.is_direct_chat = 0"#,
|
||||
)
|
||||
.execute(&mut **connection)
|
||||
.await?;
|
||||
|
||||
for table in ["contact_groups", "contact_group_members"] {
|
||||
let rows = sqlx::query_scalar::<_, i64>(AssertSqlSafe(format!(
|
||||
r#"SELECT COUNT(*) FROM main."{table}""#
|
||||
)))
|
||||
.fetch_one(&mut **connection)
|
||||
.await?;
|
||||
counts.push(TableMigrationCount {
|
||||
table: table.to_owned(),
|
||||
rows,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn common_columns(
|
||||
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
table: &str,
|
||||
|
|
@ -239,8 +316,7 @@ async fn copy_sequences(connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>) ->
|
|||
"verification_tokens",
|
||||
"user_discovery_own_promotions",
|
||||
"user_discovery_shares",
|
||||
"shortcuts",
|
||||
"labels",
|
||||
"contact_groups",
|
||||
] {
|
||||
let source_sequence: Option<i64> =
|
||||
sqlx::query_scalar(r#"SELECT seq FROM legacy.sqlite_sequence WHERE name = ?"#)
|
||||
|
|
@ -279,7 +355,7 @@ mod tests {
|
|||
let legacy = AppDatabase::new(legacy_path.to_str().unwrap(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
legacy.run_migrations().await.unwrap();
|
||||
create_legacy_schema(&legacy.pool).await;
|
||||
sqlx::query!(r#"PRAGMA user_version = 25"#)
|
||||
.execute(&legacy.pool)
|
||||
.await
|
||||
|
|
@ -294,11 +370,51 @@ mod tests {
|
|||
target.run_migrations().await.unwrap();
|
||||
let first = target.import_legacy(&legacy_path).await.unwrap();
|
||||
assert_eq!(first.tables.len(), APPLICATION_TABLES.len());
|
||||
assert!(first.tables.iter().all(|entry| entry.rows == 1));
|
||||
assert!(first
|
||||
.tables
|
||||
.iter()
|
||||
.filter(|entry| !entry.table.starts_with("contact_group"))
|
||||
.all(|entry| entry.rows == 1));
|
||||
assert_eq!(
|
||||
first
|
||||
.tables
|
||||
.iter()
|
||||
.find(|entry| entry.table == "contact_groups")
|
||||
.unwrap()
|
||||
.rows,
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.tables
|
||||
.iter()
|
||||
.find(|entry| entry.table == "contact_group_members")
|
||||
.unwrap()
|
||||
.rows,
|
||||
2
|
||||
);
|
||||
let second = target.import_legacy(&legacy_path).await.unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(target.is_legacy_import_complete().await.unwrap());
|
||||
|
||||
let migrated_shortcut = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||
r#"SELECT name, emoji, background_color, show_as_shortcut
|
||||
FROM contact_groups WHERE emoji = '🔥'"#,
|
||||
)
|
||||
.fetch_one(&target.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(migrated_shortcut, ("🔥".into(), "🔥".into(), 0, 1));
|
||||
let migrated_group_target: String = sqlx::query_scalar(
|
||||
r#"SELECT group_id
|
||||
FROM contact_group_members
|
||||
WHERE group_id IS NOT NULL"#,
|
||||
)
|
||||
.fetch_one(&target.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(migrated_group_target, "group-1");
|
||||
|
||||
let selected = target
|
||||
.raw_select(
|
||||
"SELECT username, avatar_svg_compressed FROM contacts WHERE user_id = ?".to_owned(),
|
||||
|
|
@ -369,6 +485,87 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
async fn create_legacy_schema(pool: &SqlitePool) {
|
||||
for migration in [
|
||||
include_str!("migrations/0001_initial.sql"),
|
||||
include_str!("migrations/0002_api_outbox.sql"),
|
||||
include_str!("migrations/0003_notification_outbox.sql"),
|
||||
include_str!("migrations/0004_sealed_sender.sql"),
|
||||
include_str!("migrations/0005_direct_media_upload.sql"),
|
||||
include_str!("migrations/0006_defer_receipts_missing_bundle.sql"),
|
||||
include_str!("migrations/0007_remove_experimental_transport.sql"),
|
||||
include_str!("migrations/0008_pending_plaintext.sql"),
|
||||
include_str!("migrations/0009_outbox_dispatch.sql"),
|
||||
include_str!("migrations/0010_media_trim.sql"),
|
||||
include_str!("migrations/0011_outgoing_contact_request.sql"),
|
||||
] {
|
||||
sqlx::raw_sql(migration).execute(pool).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rust_migration_converts_direct_users_and_actual_groups() {
|
||||
let directory = tempdir().unwrap();
|
||||
let path = directory.path().join("pre-contact-groups.sqlite");
|
||||
let database = AppDatabase::new(path.to_str().unwrap(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
create_legacy_schema(&database.pool).await;
|
||||
for statement in [
|
||||
"INSERT INTO contacts(user_id, username) VALUES(7, 'alice')",
|
||||
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES('direct-1', 'Alice', 1)",
|
||||
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES('group-1', 'Friends', 0)",
|
||||
"INSERT INTO group_members(group_id, contact_id) VALUES('direct-1', 7)",
|
||||
"INSERT INTO shortcuts(id, emoji, usage_counter) VALUES(3, '🔥', 5)",
|
||||
"INSERT INTO shortcut_members(shortcut_id, group_id) VALUES(3, 'direct-1')",
|
||||
"INSERT INTO shortcut_members(shortcut_id, group_id) VALUES(3, 'group-1')",
|
||||
"INSERT INTO labels(id, name, text_color, background_color) VALUES(5, 'Family', 1, 2)",
|
||||
"INSERT INTO contact_labels(contact_id, label_id) VALUES(7, 5)",
|
||||
] {
|
||||
sqlx::query(statement)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
sqlx::raw_sql(include_str!("migrations/0012_contact_groups.sql"))
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let shortcut = sqlx::query_as::<_, (i64, String, String, i64, i64, i64)>(
|
||||
r#"SELECT id, name, emoji, background_color, show_as_shortcut, usage_counter
|
||||
FROM contact_groups WHERE emoji = '🔥'"#,
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(shortcut, (8, "🔥".into(), "🔥".into(), 0, 1, 5));
|
||||
|
||||
let members = sqlx::query_as::<_, (Option<i64>, Option<String>)>(
|
||||
r#"SELECT user_id, group_id
|
||||
FROM contact_group_members
|
||||
WHERE contact_group_id = 8
|
||||
ORDER BY group_id"#,
|
||||
)
|
||||
.fetch_all(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
members,
|
||||
vec![(Some(7), None), (None, Some("group-1".into()))]
|
||||
);
|
||||
let old_table_count: i64 = sqlx::query_scalar(
|
||||
r#"SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN ('shortcuts', 'shortcut_members', 'labels', 'contact_labels')"#,
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old_table_count, 0);
|
||||
}
|
||||
|
||||
async fn populate_every_table(pool: &SqlitePool) {
|
||||
let statements = [
|
||||
"INSERT INTO contacts(user_id, username, avatar_svg_compressed, signal_version) VALUES(7, 'alice', x'0001FF', 'v2')",
|
||||
|
|
|
|||
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 const APP_DATABASE_FILE: &str = "app_db.sqlite";
|
||||
pub const APP_SCHEMA_VERSION: i64 = 6;
|
||||
pub const APP_SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
/// Tables imported from the legacy Drift database. Every entry must exist in
|
||||
/// Drift schema 25, because a missing table aborts the whole import. Rust-only
|
||||
/// tables such as `notification_outbox` are deliberately absent: they have no
|
||||
/// legacy counterpart, and importing stale rows would replay old notifications.
|
||||
/// User-owned application tables in the current Rust schema. Rust-only outbox
|
||||
/// tables are deliberately absent because they are reconstructed locally.
|
||||
pub const APPLICATION_TABLES: &[&str] = &[
|
||||
"contacts",
|
||||
"groups",
|
||||
|
|
@ -41,10 +39,31 @@ pub const APPLICATION_TABLES: &[&str] = &[
|
|||
"user_discovery_other_promotions",
|
||||
"user_discovery_own_promotions",
|
||||
"user_discovery_shares",
|
||||
"shortcuts",
|
||||
"shortcut_members",
|
||||
"labels",
|
||||
"contact_labels",
|
||||
"contact_groups",
|
||||
"contact_group_members",
|
||||
];
|
||||
|
||||
/// Tables copied one-to-one from the legacy Drift schema. Contact labels and
|
||||
/// shortcuts need a typed transformation into the unified contact-group tables.
|
||||
pub const LEGACY_COPY_TABLES: &[&str] = &[
|
||||
"contacts",
|
||||
"groups",
|
||||
"media_files",
|
||||
"messages",
|
||||
"message_histories",
|
||||
"reactions",
|
||||
"group_members",
|
||||
"receipts",
|
||||
"received_receipts",
|
||||
"message_actions",
|
||||
"group_histories",
|
||||
"key_verifications",
|
||||
"verification_tokens",
|
||||
"user_discovery_announced_users",
|
||||
"user_discovery_user_relations",
|
||||
"user_discovery_other_promotions",
|
||||
"user_discovery_own_promotions",
|
||||
"user_discovery_shares",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::api::proto::http_requests::{
|
|||
};
|
||||
use crate::bridge::api::RustApi;
|
||||
use crate::context::Context;
|
||||
use crate::database::app::tables::MediaFile;
|
||||
use crate::database::app::{tables::MediaFile, AppDatabase};
|
||||
use crate::error::{Result, TwonlyError};
|
||||
use crate::native::transfer;
|
||||
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
|
||||
|
|
@ -34,10 +34,16 @@ const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
|
|||
const DEFAULT_SLOT_REFILL_THRESHOLD: i64 = 5;
|
||||
/// Where the server's advertised refill threshold is remembered between runs.
|
||||
const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
|
||||
/// Upload slots and attachment capabilities belong to exactly one API
|
||||
/// deployment. Debug and profile builds share the `.testing` application data,
|
||||
/// so this marker prevents a build switch from reusing the other server's
|
||||
/// durable jobs.
|
||||
const API_NAMESPACE_KEY: &str = "direct_media_api_namespace";
|
||||
const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2);
|
||||
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||
|
||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
||||
static API_NAMESPACE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// Bumped every time something asks to be watched. A watcher already in flight
|
||||
/// reads this as "new work arrived" and drops back to the short poll interval.
|
||||
|
|
@ -180,6 +186,68 @@ struct NativeUploadDescriptor {
|
|||
complete: NativeRequest,
|
||||
}
|
||||
|
||||
async fn reset_upload_namespace(
|
||||
database: &AppDatabase,
|
||||
data_dir: &Path,
|
||||
namespace: &str,
|
||||
) -> Result<(Option<String>, usize)> {
|
||||
let stored = sqlx::query_scalar::<_, String>("SELECT value FROM app_metadata WHERE key = ?")
|
||||
.bind(API_NAMESPACE_KEY)
|
||||
.fetch_optional(&database.pool)
|
||||
.await?;
|
||||
if stored.as_deref() == Some(namespace) {
|
||||
return Ok((stored, 0));
|
||||
}
|
||||
|
||||
let stale_files = sqlx::query_as::<_, (String, String, String, String)>(
|
||||
r#"SELECT attachment_id, multipart_path, manifest_path, complete_body_path
|
||||
FROM direct_media_upload_jobs"#,
|
||||
)
|
||||
.fetch_all(&database.pool)
|
||||
.await?;
|
||||
|
||||
let mut transaction = database.pool.begin().await?;
|
||||
sqlx::query(
|
||||
r#"UPDATE media_files
|
||||
SET upload_state = 'preprocessing', pre_progressing_process = NULL
|
||||
WHERE upload_state != 'uploaded'
|
||||
AND media_id IN (SELECT media_id FROM direct_media_upload_jobs)"#,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM direct_media_upload_jobs WHERE 1")
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM direct_media_upload_slots WHERE 1")
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT INTO app_metadata(key, value) VALUES(?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"#,
|
||||
)
|
||||
.bind(API_NAMESPACE_KEY)
|
||||
.bind(namespace)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
for (attachment_id, multipart, manifest, complete) in &stale_files {
|
||||
for path in [multipart, manifest, complete] {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(path, %error, "could not remove stale upload request file");
|
||||
}
|
||||
}
|
||||
}
|
||||
let job_dir = data_dir.join("direct-media-upload").join(attachment_id);
|
||||
let _ = std::fs::remove_dir(job_dir);
|
||||
}
|
||||
|
||||
Ok((stored, stale_files.len()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Outcome {
|
||||
Uploaded,
|
||||
|
|
@ -200,6 +268,34 @@ impl DirectMediaUploadService {
|
|||
format!("{}{}", RustApi::api_base_url("https".into()), path)
|
||||
}
|
||||
|
||||
/// Invalidates durable upload state created for another API deployment.
|
||||
///
|
||||
/// Attachment ids, capabilities, pre-signed object-store requests, and the
|
||||
/// manifest/complete URLs are all server-specific. Keeping them when a
|
||||
/// profile build (production API) is replaced by a debug build (development
|
||||
/// API), or vice versa, leaves the media in `backgroundUploadTaskStarted`
|
||||
/// until the old slot expires. Returning the media to `preprocessing` lets
|
||||
/// the ordinary startup sweep reserve a fresh slot and really retry it.
|
||||
async fn ensure_api_namespace(&self) -> Result<()> {
|
||||
let _guard = API_NAMESPACE_LOCK.lock().await;
|
||||
let namespace = RustApi::api_base_url("https".into());
|
||||
let database = self.ctx.app_db.read().await.clone();
|
||||
let (stored, restarted) =
|
||||
reset_upload_namespace(&database, Path::new(&self.ctx.config.data_dir), &namespace)
|
||||
.await?;
|
||||
if stored.as_deref() == Some(namespace.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
previous = stored.as_deref().unwrap_or("unset"),
|
||||
current = namespace,
|
||||
restarted,
|
||||
"reset direct-media uploads after API deployment changed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn authenticated_request(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
|
|
@ -230,6 +326,7 @@ impl DirectMediaUploadService {
|
|||
}
|
||||
|
||||
pub async fn preload_slots(&self) -> Result<usize> {
|
||||
self.ensure_api_namespace().await?;
|
||||
let database = self.ctx.app_db.read().await.clone();
|
||||
let known = sqlx::query_scalar::<_, String>(
|
||||
"SELECT attachment_id FROM direct_media_upload_slots WHERE state IN ('cached', 'reserved') AND expires_at > CAST(strftime('%s','now') AS INTEGER)",
|
||||
|
|
@ -332,6 +429,7 @@ impl DirectMediaUploadService {
|
|||
}
|
||||
|
||||
async fn ensure_slots(&self) -> Result<()> {
|
||||
self.ensure_api_namespace().await?;
|
||||
let usable = self.usable_slot_count().await?;
|
||||
if usable > self.refill_threshold().await {
|
||||
return Ok(());
|
||||
|
|
@ -391,6 +489,7 @@ impl DirectMediaUploadService {
|
|||
/// while the manifest is still being reconciled, so only the attachment's
|
||||
/// own state says whether the recipients were dispatched.
|
||||
pub async fn reconcile(&self) -> Result<()> {
|
||||
self.ensure_api_namespace().await?;
|
||||
let database = self.ctx.app_db.read().await.clone();
|
||||
let jobs = sqlx::query_as::<_, PendingJob>(
|
||||
r#"SELECT j.attachment_id, j.media_id, j.multipart_path, j.manifest_path,
|
||||
|
|
@ -873,6 +972,91 @@ mod tests {
|
|||
assert!(!WATCHING.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_namespace_change_restarts_durable_uploads() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let database_path = directory.path().join("app.sqlite");
|
||||
let database = AppDatabase::new(database_path.to_str().unwrap(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
database.run_migrations().await.unwrap();
|
||||
|
||||
let job_dir = directory.path().join("direct-media-upload/attachment-1");
|
||||
std::fs::create_dir_all(&job_dir).unwrap();
|
||||
let multipart = job_dir.join("media.multipart");
|
||||
let manifest = job_dir.join("manifest.pb");
|
||||
let complete = job_dir.join("complete.pb");
|
||||
for path in [&multipart, &manifest, &complete] {
|
||||
std::fs::write(path, b"request").unwrap();
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO app_metadata(key, value) VALUES(?, 'https://api.example/api/')")
|
||||
.bind(API_NAMESPACE_KEY)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files(media_id, type, upload_state, pre_progressing_process) VALUES('media-1', 'image', 'backgroundUploadTaskStarted', 73)",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO direct_media_upload_slots
|
||||
(attachment_id, expires_at, maximum_object_bytes, upload_url,
|
||||
upload_fields_json, capability, state)
|
||||
VALUES('attachment-1', 9999999999, 1000000, 'https://objects.example',
|
||||
'{}', x'01', 'reserved')"#,
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO direct_media_upload_jobs
|
||||
(attachment_id, media_id, multipart_path, manifest_path,
|
||||
complete_body_path, native_descriptor_json, state, expires_at)
|
||||
VALUES('attachment-1', 'media-1', ?, ?, ?, '{}', 'scheduled', 9999999999)"#,
|
||||
)
|
||||
.bind(multipart.to_string_lossy().as_ref())
|
||||
.bind(manifest.to_string_lossy().as_ref())
|
||||
.bind(complete.to_string_lossy().as_ref())
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (previous, restarted) =
|
||||
reset_upload_namespace(&database, directory.path(), "https://dev-api.example/api/")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(previous.as_deref(), Some("https://api.example/api/"));
|
||||
assert_eq!(restarted, 1);
|
||||
let state = sqlx::query_as::<_, (Option<String>, Option<i64>)>(
|
||||
"SELECT upload_state, pre_progressing_process FROM media_files WHERE media_id = 'media-1'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state, (Some("preprocessing".into()), None));
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM direct_media_upload_jobs")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM direct_media_upload_slots")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(!multipart.exists());
|
||||
assert!(!manifest.exists());
|
||||
assert!(!complete.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
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