Merge pull request 'Paginate sticker pack access' (#298) from feat/sticker-pagination into master

Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/298
This commit is contained in:
PapaTutuWawa 2023-07-11 11:20:21 +00:00
commit 1e94910ebd
26 changed files with 763 additions and 499 deletions

View File

@ -376,6 +376,9 @@
"importFailure": "Failed to import sticker pack",
"stickerPackSize": "(${size})"
},
"stickerPacks": {
"title": "Sticker Packs"
},
"storage": {
"title": "Storage",
"storageUsed": "Storage used: ${size}",

View File

@ -36,9 +36,6 @@ files:
roster:
type: List<RosterItem>?
deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Triggered if a conversation has been added.
# Also returned by [AddConversationCommand]
- name: ConversationAddedEvent
@ -322,6 +319,22 @@ files:
conversations:
type: List<Conversation>
deserialize: true
- name: PagedStickerPackResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPacks:
type: List<StickerPack>
deserialise: true
- name: GetStickerPackByIdResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack?
deserialise: true
generate_builder: true
builder_name: "Event"
builder_baseclass: "BackgroundEvent"
@ -643,6 +656,20 @@ files:
# Milliseconds from now in the past; The maximum age of a file to not
# get deleted.
timeOffset: int
- name: GetPagedStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
olderThan: bool
timestamp: int?
includeStickers: bool
- name: GetStickerPackByIdCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
id: String
- name: DebugCommand
extends: BackgroundCommand
implements:

View File

@ -56,6 +56,7 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
import 'package:moxxyv2/ui/pages/settings/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.dart';
import 'package:moxxyv2/ui/pages/settings/sticker_packs.dart';
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart';
import 'package:moxxyv2/ui/pages/settings/storage/storage.dart';
@ -334,6 +335,8 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
case stickersRoute:
return StickersSettingsPage.route;
case stickerPacksRoute:
return StickerPacksSettingsPage.route;
case stickerPackRoute:
return StickerPackPage.route;
case storageSettingsRoute:

View File

@ -183,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
restricted INTEGER NOT NULL
restricted INTEGER NOT NULL,
addedTimestamp INTEGER NOT NULL
)''',
);

View File

@ -45,6 +45,7 @@ import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
import 'package:moxxyv2/service/database/migrations/0003_new_omemo.dart';
import 'package:moxxyv2/service/database/migrations/0003_new_omemo_pseudo_messages.dart';
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.dart';
import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart';
// ignore: implementation_imports
@ -152,6 +153,7 @@ const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(39, upgradeFromV38ToV39),
DatabaseMigration(40, upgradeFromV39ToV40),
DatabaseMigration(41, upgradeFromV40ToV41),
DatabaseMigration(42, upgradeFromV41ToV42),
];
class DatabaseService {

View File

@ -0,0 +1,30 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV41ToV42(Database db) async {
/// Add the new column
await db.execute(
'''
ALTER TABLE $stickerPacksTable ADD COLUMN addedTimestamp INTEGER NOT NULL DEFAULT 0;
''',
);
/// Ensure that the sticker packs are sorted (albeit randomly)
final stickerPackIds = await db.query(
stickerPacksTable,
columns: ['id'],
);
var counter = 0;
for (final id in stickerPackIds) {
await db.update(
stickerPacksTable,
{
'addedTimestamp': counter,
},
where: 'id = ?',
whereArgs: [id],
);
counter++;
}
}

View File

@ -108,6 +108,8 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<RequestAvatarForJidCommand>(performRequestAvatarForJid),
EventTypeMatcher<GetStorageUsageCommand>(performGetStorageUsage),
EventTypeMatcher<DeleteOldMediaFilesCommand>(performOldMediaFileDeletion),
EventTypeMatcher<GetPagedStickerPackCommand>(performGetPagedStickerPacks),
EventTypeMatcher<GetStickerPackByIdCommand>(performGetStickerPackById),
EventTypeMatcher<DebugCommand>(performDebugCommand),
]);
@ -189,7 +191,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
.where((c) => c.open)
.toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
);
}
@ -1205,6 +1206,7 @@ Future<void> performFetchStickerPack(
stickerPack.restricted,
false,
0,
0,
),
),
id: id,
@ -1375,6 +1377,38 @@ Future<void> performOldMediaFileDeletion(
);
}
Future<void> performGetPagedStickerPacks(
GetPagedStickerPackCommand command, {
dynamic extra,
}) async {
final result = await GetIt.I.get<StickersService>().getPaginatedStickerPacks(
command.olderThan,
command.timestamp,
command.includeStickers,
);
sendEvent(
PagedStickerPackResult(
stickerPacks: result,
),
id: extra as String,
);
}
Future<void> performGetStickerPackById(
GetStickerPackByIdCommand command, {
dynamic extra,
}) async {
sendEvent(
GetStickerPackByIdResult(
stickerPack: await GetIt.I.get<StickersService>().getStickerPackById(
command.id,
),
),
id: extra as String,
);
}
Future<void> performDebugCommand(
DebugCommand command, {
dynamic extra,

View File

@ -68,7 +68,8 @@ Future<String> computeCachedPathForFile(
return path.join(
basePath,
hash != null
? '$hash.$ext'
// NOTE: [ext] already includes a leading "."
? '$hash$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
);
}

View File

@ -16,6 +16,7 @@ import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
@ -24,7 +25,6 @@ import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p;
class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService');
/// Computes the total amount of storage occupied by the stickers in the sticker
@ -59,8 +59,6 @@ class StickersService {
}
Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query(
stickerPacksTable,
@ -106,7 +104,7 @@ JOIN
[id],
);
_stickerPacks[id] = StickerPack.fromDatabaseJson(
final stickerPack = StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers.map((sticker) {
return Sticker.fromDatabaseJson(
@ -120,26 +118,11 @@ JOIN
size: await getStickerPackSizeById(id),
);
return _stickerPacks[id]!;
}
Future<List<StickerPack>> getStickerPacks() async {
if (_stickerPacks.isEmpty) {
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
stickerPacksTable,
columns: ['id'],
);
for (final rawPack in rawPackIds) {
final id = rawPack['id']! as String;
await getStickerPackById(id);
}
}
_log.finest('Got ${_stickerPacks.length} sticker packs');
return _stickerPacks.values.toList();
return stickerPack;
}
Future<void> removeStickerPack(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist');
@ -160,15 +143,17 @@ JOIN
}
// Remove from the database
await GetIt.I.get<DatabaseService>().database.delete(
await db.delete(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
await db.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final result = await GetIt.I
@ -440,6 +425,7 @@ JOIN
pack.hashValue,
pack.restricted,
true,
DateTime.now().millisecondsSinceEpoch,
0,
);
await _addStickerPackFromData(stickerPack);
@ -487,8 +473,11 @@ JOIN
// Only copy the sticker to storage if we don't already have it
var fm = fileMetadataRaw.fileMetadata;
if (!fileMetadataRaw.retrieved &&
if (!fileMetadataRaw.retrieved ||
fileMetadataRaw.fileMetadata.path == null) {
_log.finest(
'Copying sticker ${sticker.metadata.name!} to media storage',
);
final stickerFile = archive.findFile(sticker.metadata.name!)!;
final file = File(stickerPath);
await file.writeAsBytes(
@ -499,12 +488,21 @@ JOIN
fm = await fs.updateFileMetadata(
fm.id,
size: file.lengthSync(),
path: stickerPath,
);
size += file.lengthSync();
} else {
_log.finest(
'Not copying sticker ${sticker.metadata.name!} as we already have it',
);
}
// Check if the sticker has size
if (fm.size == null) {
_log.finest(
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
);
// Update the File Metadata entry
fm = await fs.updateFileMetadata(
fm.id,
@ -520,7 +518,7 @@ JOIN
pack.hashValue,
sticker.metadata.desc!,
sticker.suggests,
fileMetadataRaw.fileMetadata,
fm,
),
);
}
@ -530,9 +528,6 @@ JOIN
size: size,
);
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info(
'Sticker pack ${stickerPack.id} successfully added to the database',
);
@ -541,4 +536,110 @@ JOIN
unawaited(_publishStickerPack(pack));
return stickerPackWithStickers;
}
/// Returns a paginated list of sticker packs.
/// [includeStickers] controls whether the stickers for a given sticker pack are
/// fetched from the database. Setting this to false, i.e. not loading the stickers,
/// can be useful, for example, when we're only interested in listing the sticker
/// packs without the stickers being visible.
Future<List<StickerPack>> getPaginatedStickerPacks(
bool olderThan,
int? timestamp,
bool includeStickers,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = timestamp != null ? 'addedTimestamp $comparator ?' : null;
final stickerPacksRaw = await db.query(
stickerPacksTable,
where: query,
orderBy: 'addedTimestamp DESC',
limit: stickerPackPaginationSize,
);
final stickerPacks = List<StickerPack>.empty(growable: true);
for (final pack in stickerPacksRaw) {
// Query the stickers
List<Map<String, Object?>> stickersRaw;
if (includeStickers) {
stickersRaw = await db.rawQuery(
'''
SELECT
st.*,
fm.id AS fm_id,
fm.path AS fm_path,
fm.sourceUrls AS fm_sourceUrls,
fm.mimeType AS fm_mimeType,
fm.thumbnailType AS fm_thumbnailType,
fm.thumbnailData AS fm_thumbnailData,
fm.width AS fm_width,
fm.height AS fm_height,
fm.plaintextHashes AS fm_plaintextHashes,
fm.encryptionKey AS fm_encryptionKey,
fm.encryptionIv AS fm_encryptionIv,
fm.encryptionScheme AS fm_encryptionScheme,
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM
$stickersTable AS st,
$fileMetadataTable AS fm
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[
pack['id']! as String,
],
);
} else {
stickersRaw = List<Map<String, Object?>>.empty();
}
final stickerPack = StickerPack.fromDatabaseJson(
pack,
stickersRaw.map((sticker) {
return Sticker.fromDatabaseJson(
sticker,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(sticker, 'fm_'),
),
);
}).toList(),
);
/// If stickers were not requested, we still have to get the size of the
/// sticker pack anyway.
int size;
if (includeStickers && stickerPack.stickers.isNotEmpty) {
size = stickerPack.stickers
.map((sticker) => sticker.fileMetadata.size ?? 0)
.reduce((value, element) => value + element);
} else {
final sizeResult = await db.rawQuery(
'''
SELECT
SUM(fm.size) as size
FROM
$fileMetadataTable as fm,
$stickersTable as st
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[pack['id']! as String],
);
size = sizeResult.first['size'] as int? ?? 0;
}
stickerPacks.add(
stickerPack.copyWith(
size: size,
),
);
}
return stickerPacks;
}
}

View File

@ -14,3 +14,9 @@ const int maxSharedMediaPages = 3;
/// The amount of conversations for which we cache the first page.
const int conversationMessagePageCacheSize = 4;
/// The amount of sticker packs we fetch per paginated request
const stickerPackPaginationSize = 10;
/// The amount of sticker packs we can cache in memory.
const maxStickerPackPages = 2;

View File

@ -18,6 +18,9 @@ class StickerPack with _$StickerPack {
bool restricted,
bool local,
/// The timestamp (milliseconds since epoch) when the sticker pack was added
int addedTimestamp,
/// The size in bytes
int size,
) = _StickerPack;
@ -38,6 +41,7 @@ class StickerPack with _$StickerPack {
pack.restricted,
local,
0,
0,
);
/// JSON

View File

@ -1,5 +1,4 @@
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
@ -44,18 +43,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
);
// Apply
final stickerPack = GetIt.I
.get<stickers.StickersBloc>()
.state
.stickerPacks
.firstWhereOrNull(
(StickerPack pack) => pack.id == event.stickerPackId,
final stickerPackResult =
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
GetStickerPackByIdCommand(
id: event.stickerPackId,
),
) as GetStickerPackByIdResult;
assert(
stickerPackResult.stickerPack != null,
'The sticker pack must be found',
);
assert(stickerPack != null, 'The sticker pack must be found');
emit(
state.copyWith(
isWorking: false,
stickerPack: stickerPack,
stickerPack: stickerPackResult.stickerPack,
),
);
}
@ -152,21 +155,13 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
),
);
if (result is StickerPackInstallSuccessEvent) {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(result.stickerPack),
);
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
// Notify on failure
if (result is! StickerPackInstallSuccessEvent) {
await Fluttertoast.showToast(
msg: t.pages.stickerPack.fetchingFailure,
gravity: ToastGravity.SNACKBAR,
@ -179,16 +174,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
StickerPackRequested event,
Emitter<StickerPackState> emit,
) async {
// Find out if the sticker pack is locally available or not
final stickerPack = GetIt.I
.get<stickers.StickersBloc>()
.state
.stickerPacks
.firstWhereOrNull(
(StickerPack pack) => pack.id == event.stickerPackId,
emit(
state.copyWith(
isWorking: true,
),
);
if (stickerPack == null) {
final stickerPackResult =
// ignore: cast_nullable_to_non_nullable
await MoxplatformPlugin.handler.getDataSender().sendData(
GetStickerPackByIdCommand(
id: event.stickerPackId,
),
) as GetStickerPackByIdResult;
// Find out if the sticker pack is locally available or not
if (stickerPackResult.stickerPack == null) {
await _onRemoteStickerPackRequested(
RemoteStickerPackRequested(
event.stickerPackId,
@ -197,9 +198,11 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
emit,
);
} else {
await _onLocalStickerPackRequested(
LocallyAvailableStickerPackRequested(event.stickerPackId),
emit,
emit(
state.copyWith(
isWorking: false,
stickerPack: stickerPackResult.stickerPack,
),
);
}
}

View File

@ -1,17 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/painting.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
import 'package:moxxyv2/ui/helpers.dart';
part 'stickers_bloc.freezed.dart';
@ -20,59 +16,20 @@ part 'stickers_state.dart';
class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickersBloc() : super(StickersState()) {
on<StickersSetEvent>(_onStickersSet);
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<StickerPackImportedEvent>(_onStickerPackImported);
on<StickerPackAddedEvent>(_onStickerPackAdded);
}
Future<void> _onStickersSet(
StickersSetEvent event,
Emitter<StickersState> emit,
) async {
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
// of the sticker in the UI.
final map = <StickerKey, Sticker>{};
for (final pack in event.stickerPacks) {
for (final sticker in pack.stickers) {
if (!sticker.isImage) continue;
map[StickerKey(pack.id, sticker.id)] = sticker;
}
}
emit(
state.copyWith(
stickerPacks: event.stickerPacks,
stickerMap: map,
),
);
}
Future<void> _onStickerPackRemoved(
StickerPackRemovedEvent event,
Emitter<StickersState> emit,
) async {
final stickerPack = state.stickerPacks.firstWhereOrNull(
(StickerPack sp) => sp.id == event.stickerPackId,
)!;
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in stickerPack.stickers) {
sm.remove(StickerKey(stickerPack.id, sticker.id));
// Evict stickers from the cache
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
}
emit(
state.copyWith(
stickerPacks: List.from(
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
),
stickerMap: sm,
),
// Remove from the UI
BidirectionalStickerPackController.instance?.removeItem(
(stickerPack) => stickerPack.id == event.stickerPackId,
);
// Notify the backend
await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveStickerPackCommand(
stickerPackId: event.stickerPackId,
@ -103,36 +60,19 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
),
);
if (result is StickerPackImportSuccessEvent) {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in result.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
result.stickerPack,
]),
stickerMap: sm,
isImportRunning: false,
),
);
if (result is StickerPackImportSuccessEvent) {
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importSuccess,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
} else {
emit(
state.copyWith(
isImportRunning: false,
),
);
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importFailure,
gravity: ToastGravity.SNACKBAR,
@ -140,26 +80,4 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
);
}
}
Future<void> _onStickerPackAdded(
StickerPackAddedEvent event,
Emitter<StickersState> emit,
) async {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in event.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
event.stickerPack,
]),
stickerMap: sm,
),
);
}
}

View File

@ -2,13 +2,6 @@ part of 'stickers_bloc.dart';
abstract class StickersEvent {}
class StickersSetEvent extends StickersEvent {
StickersSetEvent(
this.stickerPacks,
);
final List<StickerPack> stickerPacks;
}
/// Triggered by the UI when a sticker pack has been removed
class StickerPackRemovedEvent extends StickersEvent {
StickerPackRemovedEvent(this.stickerPackId);
@ -17,9 +10,3 @@ class StickerPackRemovedEvent extends StickersEvent {
/// Triggered by the UI when a sticker pack has been imported
class StickerPackImportedEvent extends StickersEvent {}
/// Triggered by the UI when a sticker pack has been imported
class StickerPackAddedEvent extends StickersEvent {
StickerPackAddedEvent(this.stickerPack);
final StickerPack stickerPack;
}

View File

@ -20,8 +20,6 @@ class StickerKey {
@freezed
class StickersState with _$StickersState {
factory StickersState({
@Default([]) List<StickerPack> stickerPacks,
@Default({}) Map<StickerKey, Sticker> stickerMap,
@Default(false) bool isImportRunning,
}) = _StickersState;
}

View File

@ -160,6 +160,7 @@ const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String conversationSettingsRoute = '$settingsRoute/conversation';
const String appearanceRoute = '$settingsRoute/appearance';
const String stickersRoute = '$settingsRoute/stickers';
const String stickerPacksRoute = '$settingsRoute/stickers/sticker_packs';
const String storageSettingsRoute = '$settingsRoute/storage';
const String storageSharedMediaSettingsRoute = '$settingsRoute/storage/media';
const String blocklistRoute = '/blocklist';

View File

@ -213,6 +213,22 @@ class BidirectionalController<T> {
return found;
}
/// Removes the first item for which [test] returns true.
void removeItem(bool Function(T) test) {
var found = false;
for (var i = 0; i < _cache.length; i++) {
if (test(_cache[i])) {
_cache.removeAt(i);
found = true;
break;
}
}
if (found) {
_dataStreamController.add(_cache);
}
}
/// Animate to the bottom of the view.
void animateToBottom() {
_controller.animateTo(

View File

@ -0,0 +1,66 @@
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
class BidirectionalStickerPackController
extends BidirectionalController<StickerPack> {
BidirectionalStickerPackController(this.includeStickers)
: assert(
instance == null,
'There can only be one BidirectionalStickerPackController',
),
super(
pageSize: stickerPackPaginationSize,
maxPageAmount: maxStickerPackPages,
) {
instance = this;
}
/// A flag telling the UI to also include stickers in the sticker pack requests.
final bool includeStickers;
/// Singleton instance access.
static BidirectionalStickerPackController? instance;
@override
void dispose() {
super.dispose();
instance = null;
}
@override
Future<List<StickerPack>> fetchOlderDataImpl(
StickerPack? oldestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetPagedStickerPackCommand(
olderThan: true,
timestamp: oldestElement?.addedTimestamp,
includeStickers: includeStickers,
),
) as PagedStickerPackResult;
return result.stickerPacks;
}
@override
Future<List<StickerPack>> fetchNewerDataImpl(
StickerPack? newestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
GetPagedStickerPackCommand(
olderThan: false,
timestamp: newestElement?.addedTimestamp,
includeStickers: includeStickers,
),
) as PagedStickerPackResult;
return result.stickerPacks;
}
}

View File

@ -14,7 +14,6 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/avatars.dart';
@ -34,7 +33,6 @@ void setupEventHandler() {
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
EventTypeMatcher<StreamNegotiationsCompletedEvent>(
onStreamNegotiationsDone,
),
@ -183,15 +181,6 @@ Future<void> onNotificationTappend(
);
}
Future<void> onStickerPackAdded(
StickerPackAddedEvent event, {
dynamic extra,
}) async {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(event.stickerPack),
);
}
Future<void> onStreamNegotiationsDone(
StreamNegotiationsCompletedEvent event, {
dynamic extra,

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
import 'package:moxxyv2/ui/widgets/settings/row.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class StickerPacksSettingsPage extends StatefulWidget {
const StickerPacksSettingsPage({super.key});
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const StickerPacksSettingsPage(),
settings: const RouteSettings(
name: stickerPacksRoute,
),
);
@override
StickerPacksSettingsState createState() => StickerPacksSettingsState();
}
class StickerPacksSettingsState extends State<StickerPacksSettingsPage> {
final BidirectionalStickerPackController _controller =
BidirectionalStickerPackController(false);
@override
void initState() {
super.initState();
_controller.fetchOlderData();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.title(t.pages.settings.stickerPacks.title),
body: StreamBuilder<List<StickerPack>>(
stream: _controller.dataStream,
initialData: const [],
builder: (context, snapshot) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final sizeString = fileSizeToString(
snapshot.data![index].size,
);
return SettingsRow(
titleWidget: Row(
children: [
Expanded(
child: Text(
snapshot.data![index].name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
),
),
Text(
t.pages.settings.stickers
.stickerPackSize(size: sizeString),
style: const TextStyle(
color: Colors.grey,
),
),
],
),
description: snapshot.data![index].description,
crossAxisAlignment: CrossAxisAlignment.start,
prefix: const Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox(
width: 48,
height: 48,
// TODO(PapaTutuWawa): Sticker pack thumbnails would be nice
child: ClipRRect(
borderRadius: BorderRadius.all(radiusLarge),
child: ColoredBox(
color: Colors.white60,
child: Icon(
PhosphorIcons.stickerBold,
size: 32,
),
),
),
),
),
onTap: () {
GetIt.I.get<StickerPackBloc>().add(
LocallyAvailableStickerPackRequested(
snapshot.data![index].id,
),
);
},
);
},
);
},
),
);
}
}

View File

@ -2,16 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/settings/row.dart';
import 'package:moxxyv2/ui/widgets/settings/title.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class StickersSettingsPage extends StatelessWidget {
const StickersSettingsPage({super.key});
@ -26,9 +23,9 @@ class StickersSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
builder: (_, stickers) => WillPopScope(
builder: (_, stickersState) => WillPopScope(
onWillPop: () async {
return !stickers.isImportRunning;
return !stickersState.isImportRunning;
},
child: Stack(
children: [
@ -42,20 +39,13 @@ class StickersSettingsPage extends StatelessWidget {
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (_, prefs) => Padding(
padding: EdgeInsets.zero,
child: ListView.builder(
itemCount: stickers.stickerPacks.length + 1,
itemBuilder: (___, index) {
if (index == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
child: ListView(
children: [
SectionTitle(
t.pages.settings.stickers.displayStickers,
),
SettingsRow(
title:
t.pages.settings.stickers.displayStickers,
title: t.pages.settings.stickers.displayStickers,
suffix: Switch(
value: prefs.enableStickers,
onChanged: (value) {
@ -79,8 +69,7 @@ class StickersSettingsPage extends StatelessWidget {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
prefs.copyWith(
autoDownloadStickersFromContacts:
value,
autoDownloadStickersFromContacts: value,
),
),
);
@ -93,75 +82,17 @@ class StickersSettingsPage extends StatelessWidget {
StickerPackImportedEvent(),
);
},
title:
t.pages.settings.stickers.importStickerPack,
title: t.pages.settings.stickers.importStickerPack,
),
SectionTitle(
t.pages.settings.stickers.stickerPacksSection,
),
if (stickers.stickerPacks.isEmpty)
SettingsRow(
title: t.pages.conversation
.stickerPickerNoStickersLine1,
),
],
);
}
final sizeString = fileSizeToString(
stickers.stickerPacks[index - 1].size,
);
return SettingsRow(
titleWidget: Row(
children: [
Expanded(
child: Text(
stickers.stickerPacks[index - 1].name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
),
),
Text(
t.pages.settings.stickers
.stickerPackSize(size: sizeString),
style: const TextStyle(
color: Colors.grey,
),
),
],
),
description:
stickers.stickerPacks[index - 1].description,
crossAxisAlignment: CrossAxisAlignment.start,
prefix: const Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox(
width: 48,
height: 48,
// TODO(PapaTutuWawa): Sticker pack thumbnails would be nice
child: ClipRRect(
borderRadius: BorderRadius.all(radiusLarge),
child: ColoredBox(
color: Colors.white60,
child: Icon(
PhosphorIcons.stickerBold,
size: 32,
),
),
),
),
),
title: t.pages.settings.storage.manageStickers,
onTap: () {
GetIt.I.get<StickerPackBloc>().add(
LocallyAvailableStickerPackRequested(
stickers.stickerPacks[index - 1].id,
Navigator.of(context).pushNamed(
stickerPacksRoute,
);
},
),
);
},
);
},
],
),
),
),
@ -175,9 +106,9 @@ class StickersSettingsPage extends StatelessWidget {
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
curve: Curves.decelerate,
opacity: stickers.isImportRunning ? 1 : 0,
opacity: stickersState.isImportRunning ? 1 : 0,
child: IgnorePointer(
ignoring: !stickers.isImportRunning,
ignoring: !stickersState.isImportRunning,
child: const ColoredBox(
color: Colors.black54,
child: Align(

View File

@ -254,7 +254,7 @@ class StorageSettingsPageState extends State<StorageSettingsPage> {
SettingsRow(
title: t.pages.settings.storage.manageStickers,
onTap: () {
Navigator.of(context).pushNamed(stickersRoute);
Navigator.of(context).pushNamed(stickerPacksRoute);
},
),
],

View File

@ -24,9 +24,23 @@ class StickerWrapper extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (sticker.fileMetadata.path != null) {
return Image.file(
return Image(
image: ResizeImage.resizeIfNeeded(
null,
null,
FileImage(
File(sticker.fileMetadata.path!),
),
),
fit: cover ? BoxFit.contain : null,
loadingBuilder: (_, child, event) {
if (event == null) return child;
return const ClipRRect(
borderRadius: BorderRadius.all(radiusLarge),
child: ShimmerWidget(),
);
},
);
} else {
return Image.network(
@ -132,7 +146,8 @@ class StickerPackPage extends StatelessWidget {
}
Widget _buildBody(BuildContext context, StickerPackState state) {
return Column(
return SingleChildScrollView(
child: Column(
children: [
Row(
children: [
@ -211,6 +226,7 @@ class StickerPackPage extends StatelessWidget {
),
),
],
),
);
}

View File

@ -9,7 +9,6 @@ import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/sharing.dart';
@ -48,11 +47,6 @@ Future<void> preStartDone(PreStartDoneEvent result, {dynamic extra}) async {
result.roster!,
),
);
GetIt.I.get<StickersBloc>().add(
StickersSetEvent(
result.stickers!,
),
);
GetIt.I.get<ShareSelectionBloc>().add(
ShareSelectionInitEvent(
result.conversations!,

View File

@ -2,13 +2,11 @@ import 'dart:async';
import 'dart:io';
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
@ -215,7 +213,7 @@ class ConversationsListRowState extends State<ConversationsListRow> {
);
}
Widget _buildLastMessagePreview(StickersState state) {
Widget _buildLastMessagePreview() {
Widget? preview;
if (widget.conversation.lastMessage!.stickerPackId != null) {
if (widget.conversation.lastMessage!.fileMetadata!.path != null) {
@ -417,17 +415,7 @@ class ConversationsListRowState extends State<ConversationsListRow> {
!widget.conversation.isTyping)
Padding(
padding: const EdgeInsets.only(right: 5),
child:
BlocBuilder<StickersBloc, StickersState>(
buildWhen: (prev, next) =>
prev.stickerPacks.length !=
next.stickerPacks.length &&
widget.conversation.lastMessage
?.stickerPackId !=
null,
builder: (_, state) =>
_buildLastMessagePreview(state),
),
child: _buildLastMessagePreview(),
)
else
const SizedBox(height: 30),

View File

@ -10,6 +10,7 @@ import 'package:moxxyv2/ui/bloc/navigation_bloc.dart' as nav;
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
/// A wrapper data class to group by a sticker pack's id, but display its title.
@immutable
@ -36,25 +37,57 @@ class _StickerPackSeparator {
int get hashCode => name.hashCode ^ id.hashCode;
}
class StickerPicker extends StatelessWidget {
class StickerPicker extends StatefulWidget {
StickerPicker({
required this.width,
required this.onStickerTapped,
super.key,
}) {
_itemSize = (width - 2 * 15 - 3 * 30) / 4;
itemSize = (width - 2 * 15 - 3 * 30) / 4;
}
final double width;
late final double _itemSize;
final void Function(Sticker) onStickerTapped;
Widget _buildList(BuildContext context, StickersState state) {
// TODO(PapaTutuWawa): Solve this somewhere else
final stickerPacks =
state.stickerPacks.where((pack) => !pack.restricted).toList();
late final double itemSize;
if (stickerPacks.isEmpty) {
@override
StickerPickerState createState() => StickerPickerState();
}
class StickerPickerState extends State<StickerPicker> {
final BidirectionalStickerPackController _controller =
BidirectionalStickerPackController(true);
@override
void initState() {
super.initState();
// Fetch the initial state
_controller.fetchOlderData();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: StreamBuilder<List<StickerPack>>(
stream: _controller.dataStream,
initialData: const [],
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.none &&
snapshot.connectionState != ConnectionState.waiting &&
snapshot.data!.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Align(
@ -69,7 +102,9 @@ class StickerPicker extends StatelessWidget {
onPressed: () {
context.read<nav.NavigationBloc>().add(
nav.PushedNamedEvent(
const nav.NavigationDestination(stickersRoute),
const nav.NavigationDestination(
stickersRoute,
),
),
);
},
@ -84,7 +119,8 @@ class StickerPicker extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GroupedListView<StickerPack, _StickerPackSeparator>(
elements: stickerPacks,
controller: _controller.scrollController,
elements: snapshot.data!,
groupBy: (stickerPack) => _StickerPackSeparator(
stickerPack.name,
stickerPack.id,
@ -101,10 +137,12 @@ class StickerPicker extends StatelessWidget {
),
),
sort: false,
indexedItemBuilder: (context, stickerPack, index) => GridView.builder(
indexedItemBuilder: (context, stickerPack, index) =>
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
@ -113,7 +151,7 @@ class StickerPicker extends StatelessWidget {
itemBuilder: (_, index) {
return InkWell(
onTap: () {
onStickerTapped(
widget.onStickerTapped(
stickerPack.stickers[index],
);
},
@ -132,23 +170,16 @@ class StickerPicker extends StatelessWidget {
),
key: ValueKey('${stickerPack.id}_$index'),
fit: BoxFit.contain,
width: _itemSize,
height: _itemSize,
width: widget.itemSize,
height: widget.itemSize,
),
);
},
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: _buildList(context, state),
},
),
);
},
);