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", "importFailure": "Failed to import sticker pack",
"stickerPackSize": "(${size})" "stickerPackSize": "(${size})"
}, },
"stickerPacks": {
"title": "Sticker Packs"
},
"storage": { "storage": {
"title": "Storage", "title": "Storage",
"storageUsed": "Storage used: ${size}", "storageUsed": "Storage used: ${size}",

View File

@ -36,9 +36,6 @@ files:
roster: roster:
type: List<RosterItem>? type: List<RosterItem>?
deserialise: true deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Triggered if a conversation has been added. # Triggered if a conversation has been added.
# Also returned by [AddConversationCommand] # Also returned by [AddConversationCommand]
- name: ConversationAddedEvent - name: ConversationAddedEvent
@ -322,6 +319,22 @@ files:
conversations: conversations:
type: List<Conversation> type: List<Conversation>
deserialize: true 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 generate_builder: true
builder_name: "Event" builder_name: "Event"
builder_baseclass: "BackgroundEvent" builder_baseclass: "BackgroundEvent"
@ -643,6 +656,20 @@ files:
# Milliseconds from now in the past; The maximum age of a file to not # Milliseconds from now in the past; The maximum age of a file to not
# get deleted. # get deleted.
timeOffset: int 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 - name: DebugCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: 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/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart'; import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.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/stickers.dart';
import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart'; import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart';
import 'package:moxxyv2/ui/pages/settings/storage/storage.dart'; import 'package:moxxyv2/ui/pages/settings/storage/storage.dart';
@ -334,6 +335,8 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
); );
case stickersRoute: case stickersRoute:
return StickersSettingsPage.route; return StickersSettingsPage.route;
case stickerPacksRoute:
return StickerPacksSettingsPage.route;
case stickerPackRoute: case stickerPackRoute:
return StickerPackPage.route; return StickerPackPage.route;
case storageSettingsRoute: case storageSettingsRoute:

View File

@ -183,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
description TEXT NOT NULL, description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL, hashAlgorithm TEXT NOT NULL,
hashValue 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.dart';
import 'package:moxxyv2/service/database/migrations/0003_new_omemo_pseudo_messages.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_remove_subscriptions.dart';
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart'; import 'package:random_string/random_string.dart';
// ignore: implementation_imports // ignore: implementation_imports
@ -152,6 +153,7 @@ const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(39, upgradeFromV38ToV39), DatabaseMigration(39, upgradeFromV38ToV39),
DatabaseMigration(40, upgradeFromV39ToV40), DatabaseMigration(40, upgradeFromV39ToV40),
DatabaseMigration(41, upgradeFromV40ToV41), DatabaseMigration(41, upgradeFromV40ToV41),
DatabaseMigration(42, upgradeFromV41ToV42),
]; ];
class DatabaseService { 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<RequestAvatarForJidCommand>(performRequestAvatarForJid),
EventTypeMatcher<GetStorageUsageCommand>(performGetStorageUsage), EventTypeMatcher<GetStorageUsageCommand>(performGetStorageUsage),
EventTypeMatcher<DeleteOldMediaFilesCommand>(performOldMediaFileDeletion), EventTypeMatcher<DeleteOldMediaFilesCommand>(performOldMediaFileDeletion),
EventTypeMatcher<GetPagedStickerPackCommand>(performGetPagedStickerPacks),
EventTypeMatcher<GetStickerPackByIdCommand>(performGetStickerPackById),
EventTypeMatcher<DebugCommand>(performDebugCommand), EventTypeMatcher<DebugCommand>(performDebugCommand),
]); ]);
@ -189,7 +191,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
.where((c) => c.open) .where((c) => c.open)
.toList(), .toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(), roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
); );
} }
@ -1205,6 +1206,7 @@ Future<void> performFetchStickerPack(
stickerPack.restricted, stickerPack.restricted,
false, false,
0, 0,
0,
), ),
), ),
id: id, 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( Future<void> performDebugCommand(
DebugCommand command, { DebugCommand command, {
dynamic extra, dynamic extra,

View File

@ -68,7 +68,8 @@ Future<String> computeCachedPathForFile(
return path.join( return path.join(
basePath, basePath,
hash != null hash != null
? '$hash.$ext' // NOTE: [ext] already includes a leading "."
? '$hash$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$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/preferences.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.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; import 'package:path/path.dart' as p;
class StickersService { class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService'); final Logger _log = Logger('StickersService');
/// Computes the total amount of storage occupied by the stickers in the sticker /// 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 { Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final db = GetIt.I.get<DatabaseService>().database; final db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query( final rawPack = await db.query(
stickerPacksTable, stickerPacksTable,
@ -106,7 +104,7 @@ JOIN
[id], [id],
); );
_stickerPacks[id] = StickerPack.fromDatabaseJson( final stickerPack = StickerPack.fromDatabaseJson(
rawPack.first, rawPack.first,
rawStickers.map((sticker) { rawStickers.map((sticker) {
return Sticker.fromDatabaseJson( return Sticker.fromDatabaseJson(
@ -120,26 +118,11 @@ JOIN
size: await getStickerPackSizeById(id), size: await getStickerPackSizeById(id),
); );
return _stickerPacks[id]!; return stickerPack;
}
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();
} }
Future<void> removeStickerPack(String id) async { Future<void> removeStickerPack(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final pack = await getStickerPackById(id); final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist'); assert(pack != null, 'The sticker pack must exist');
@ -160,15 +143,17 @@ JOIN
} }
// Remove from the database // Remove from the database
await GetIt.I.get<DatabaseService>().database.delete( await db.delete(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
await db.delete(
stickerPacksTable, stickerPacksTable,
where: 'id = ?', where: 'id = ?',
whereArgs: [id], whereArgs: [id],
); );
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub // Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState(); final state = await GetIt.I.get<XmppStateService>().getXmppState();
final result = await GetIt.I final result = await GetIt.I
@ -440,6 +425,7 @@ JOIN
pack.hashValue, pack.hashValue,
pack.restricted, pack.restricted,
true, true,
DateTime.now().millisecondsSinceEpoch,
0, 0,
); );
await _addStickerPackFromData(stickerPack); await _addStickerPackFromData(stickerPack);
@ -487,8 +473,11 @@ JOIN
// Only copy the sticker to storage if we don't already have it // Only copy the sticker to storage if we don't already have it
var fm = fileMetadataRaw.fileMetadata; var fm = fileMetadataRaw.fileMetadata;
if (!fileMetadataRaw.retrieved && if (!fileMetadataRaw.retrieved ||
fileMetadataRaw.fileMetadata.path == null) { fileMetadataRaw.fileMetadata.path == null) {
_log.finest(
'Copying sticker ${sticker.metadata.name!} to media storage',
);
final stickerFile = archive.findFile(sticker.metadata.name!)!; final stickerFile = archive.findFile(sticker.metadata.name!)!;
final file = File(stickerPath); final file = File(stickerPath);
await file.writeAsBytes( await file.writeAsBytes(
@ -499,12 +488,21 @@ JOIN
fm = await fs.updateFileMetadata( fm = await fs.updateFileMetadata(
fm.id, fm.id,
size: file.lengthSync(), size: file.lengthSync(),
path: stickerPath,
); );
size += file.lengthSync(); size += file.lengthSync();
} else {
_log.finest(
'Not copying sticker ${sticker.metadata.name!} as we already have it',
);
} }
// Check if the sticker has size // Check if the sticker has size
if (fm.size == null) { if (fm.size == null) {
_log.finest(
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
);
// Update the File Metadata entry // Update the File Metadata entry
fm = await fs.updateFileMetadata( fm = await fs.updateFileMetadata(
fm.id, fm.id,
@ -520,7 +518,7 @@ JOIN
pack.hashValue, pack.hashValue,
sticker.metadata.desc!, sticker.metadata.desc!,
sticker.suggests, sticker.suggests,
fileMetadataRaw.fileMetadata, fm,
), ),
); );
} }
@ -530,9 +528,6 @@ JOIN
size: size, size: size,
); );
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info( _log.info(
'Sticker pack ${stickerPack.id} successfully added to the database', 'Sticker pack ${stickerPack.id} successfully added to the database',
); );
@ -541,4 +536,110 @@ JOIN
unawaited(_publishStickerPack(pack)); unawaited(_publishStickerPack(pack));
return stickerPackWithStickers; 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. /// The amount of conversations for which we cache the first page.
const int conversationMessagePageCacheSize = 4; 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 restricted,
bool local, bool local,
/// The timestamp (milliseconds since epoch) when the sticker pack was added
int addedTimestamp,
/// The size in bytes /// The size in bytes
int size, int size,
) = _StickerPack; ) = _StickerPack;
@ -38,6 +41,7 @@ class StickerPack with _$StickerPack {
pack.restricted, pack.restricted,
local, local,
0, 0,
0,
); );
/// JSON /// JSON

View File

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

View File

@ -1,17 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/painting.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker.dart'; import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
part 'stickers_bloc.freezed.dart'; part 'stickers_bloc.freezed.dart';
@ -20,59 +16,20 @@ part 'stickers_state.dart';
class StickersBloc extends Bloc<StickersEvent, StickersState> { class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickersBloc() : super(StickersState()) { StickersBloc() : super(StickersState()) {
on<StickersSetEvent>(_onStickersSet);
on<StickerPackRemovedEvent>(_onStickerPackRemoved); on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<StickerPackImportedEvent>(_onStickerPackImported); 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( Future<void> _onStickerPackRemoved(
StickerPackRemovedEvent event, StickerPackRemovedEvent event,
Emitter<StickersState> emit, Emitter<StickersState> emit,
) async { ) async {
final stickerPack = state.stickerPacks.firstWhereOrNull( // Remove from the UI
(StickerPack sp) => sp.id == event.stickerPackId, BidirectionalStickerPackController.instance?.removeItem(
)!; (stickerPack) => stickerPack.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,
),
); );
// Notify the backend
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveStickerPackCommand( RemoveStickerPackCommand(
stickerPackId: event.stickerPackId, stickerPackId: event.stickerPackId,
@ -103,36 +60,19 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
), ),
); );
emit(
state.copyWith(
isImportRunning: false,
),
);
if (result is StickerPackImportSuccessEvent) { 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,
),
);
await Fluttertoast.showToast( await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importSuccess, msg: t.pages.settings.stickers.importSuccess,
gravity: ToastGravity.SNACKBAR, gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT, toastLength: Toast.LENGTH_SHORT,
); );
} else { } else {
emit(
state.copyWith(
isImportRunning: false,
),
);
await Fluttertoast.showToast( await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importFailure, msg: t.pages.settings.stickers.importFailure,
gravity: ToastGravity.SNACKBAR, 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 {} 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 /// Triggered by the UI when a sticker pack has been removed
class StickerPackRemovedEvent extends StickersEvent { class StickerPackRemovedEvent extends StickersEvent {
StickerPackRemovedEvent(this.stickerPackId); StickerPackRemovedEvent(this.stickerPackId);
@ -17,9 +10,3 @@ class StickerPackRemovedEvent extends StickersEvent {
/// Triggered by the UI when a sticker pack has been imported /// Triggered by the UI when a sticker pack has been imported
class StickerPackImportedEvent extends StickersEvent {} 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 @freezed
class StickersState with _$StickersState { class StickersState with _$StickersState {
factory StickersState({ factory StickersState({
@Default([]) List<StickerPack> stickerPacks,
@Default({}) Map<StickerKey, Sticker> stickerMap,
@Default(false) bool isImportRunning, @Default(false) bool isImportRunning,
}) = _StickersState; }) = _StickersState;
} }

View File

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

View File

@ -213,6 +213,22 @@ class BidirectionalController<T> {
return found; 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. /// Animate to the bottom of the view.
void animateToBottom() { void animateToBottom() {
_controller.animateTo( _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/conversations_bloc.dart' as conversations;
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation; 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/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/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/prestart.dart'; import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/avatars.dart'; import 'package:moxxyv2/ui/service/avatars.dart';
@ -34,7 +33,6 @@ void setupEventHandler() {
EventTypeMatcher<PreStartDoneEvent>(preStartDone), EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady), EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend), EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
EventTypeMatcher<StreamNegotiationsCompletedEvent>( EventTypeMatcher<StreamNegotiationsCompletedEvent>(
onStreamNegotiationsDone, 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( Future<void> onStreamNegotiationsDone(
StreamNegotiationsCompletedEvent event, { StreamNegotiationsCompletedEvent event, {
dynamic extra, 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:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.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/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/settings/row.dart'; import 'package:moxxyv2/ui/widgets/settings/row.dart';
import 'package:moxxyv2/ui/widgets/settings/title.dart'; import 'package:moxxyv2/ui/widgets/settings/title.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart'; import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class StickersSettingsPage extends StatelessWidget { class StickersSettingsPage extends StatelessWidget {
const StickersSettingsPage({super.key}); const StickersSettingsPage({super.key});
@ -26,9 +23,9 @@ class StickersSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>( return BlocBuilder<StickersBloc, StickersState>(
builder: (_, stickers) => WillPopScope( builder: (_, stickersState) => WillPopScope(
onWillPop: () async { onWillPop: () async {
return !stickers.isImportRunning; return !stickersState.isImportRunning;
}, },
child: Stack( child: Stack(
children: [ children: [
@ -42,126 +39,60 @@ class StickersSettingsPage extends StatelessWidget {
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (_, prefs) => Padding( builder: (_, prefs) => Padding(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: ListView.builder( child: ListView(
itemCount: stickers.stickerPacks.length + 1, children: [
itemBuilder: (___, index) { SectionTitle(
if (index == 0) { t.pages.settings.stickers.displayStickers,
return Column( ),
mainAxisSize: MainAxisSize.min, SettingsRow(
crossAxisAlignment: CrossAxisAlignment.start, title: t.pages.settings.stickers.displayStickers,
children: [ suffix: Switch(
SectionTitle( value: prefs.enableStickers,
t.pages.settings.stickers.displayStickers, onChanged: (value) {
), context.read<PreferencesBloc>().add(
SettingsRow( PreferencesChangedEvent(
title: prefs.copyWith(
t.pages.settings.stickers.displayStickers, enableStickers: value,
suffix: Switch( ),
value: prefs.enableStickers, ),
onChanged: (value) { );
context.read<PreferencesBloc>().add( },
PreferencesChangedEvent(
prefs.copyWith(
enableStickers: value,
),
),
);
},
),
),
SettingsRow(
title: t.pages.settings.stickers.autoDownload,
description:
t.pages.settings.stickers.autoDownloadBody,
suffix: Switch(
value: prefs.autoDownloadStickersFromContacts,
onChanged: (value) {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
prefs.copyWith(
autoDownloadStickersFromContacts:
value,
),
),
);
},
),
),
SettingsRow(
onTap: () {
GetIt.I.get<StickersBloc>().add(
StickerPackImportedEvent(),
);
},
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,
),
),
],
), ),
),
SettingsRow(
title: t.pages.settings.stickers.autoDownload,
description: description:
stickers.stickerPacks[index - 1].description, t.pages.settings.stickers.autoDownloadBody,
crossAxisAlignment: CrossAxisAlignment.start, suffix: Switch(
prefix: const Padding( value: prefs.autoDownloadStickersFromContacts,
padding: EdgeInsets.only(right: 16), onChanged: (value) {
child: SizedBox( context.read<PreferencesBloc>().add(
width: 48, PreferencesChangedEvent(
height: 48, prefs.copyWith(
// TODO(PapaTutuWawa): Sticker pack thumbnails would be nice autoDownloadStickersFromContacts: value,
child: ClipRRect( ),
borderRadius: BorderRadius.all(radiusLarge), ),
child: ColoredBox( );
color: Colors.white60, },
child: Icon(
PhosphorIcons.stickerBold,
size: 32,
),
),
),
),
), ),
),
SettingsRow(
onTap: () { onTap: () {
GetIt.I.get<StickerPackBloc>().add( GetIt.I.get<StickersBloc>().add(
LocallyAvailableStickerPackRequested( StickerPackImportedEvent(),
stickers.stickerPacks[index - 1].id,
),
); );
}, },
); title: t.pages.settings.stickers.importStickerPack,
}, ),
SettingsRow(
title: t.pages.settings.storage.manageStickers,
onTap: () {
Navigator.of(context).pushNamed(
stickerPacksRoute,
);
},
),
],
), ),
), ),
), ),
@ -175,9 +106,9 @@ class StickersSettingsPage extends StatelessWidget {
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
curve: Curves.decelerate, curve: Curves.decelerate,
opacity: stickers.isImportRunning ? 1 : 0, opacity: stickersState.isImportRunning ? 1 : 0,
child: IgnorePointer( child: IgnorePointer(
ignoring: !stickers.isImportRunning, ignoring: !stickersState.isImportRunning,
child: const ColoredBox( child: const ColoredBox(
color: Colors.black54, color: Colors.black54,
child: Align( child: Align(

View File

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

View File

@ -24,9 +24,23 @@ class StickerWrapper extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (sticker.fileMetadata.path != null) { if (sticker.fileMetadata.path != null) {
return Image.file( return Image(
File(sticker.fileMetadata.path!), image: ResizeImage.resizeIfNeeded(
null,
null,
FileImage(
File(sticker.fileMetadata.path!),
),
),
fit: cover ? BoxFit.contain : null, fit: cover ? BoxFit.contain : null,
loadingBuilder: (_, child, event) {
if (event == null) return child;
return const ClipRRect(
borderRadius: BorderRadius.all(radiusLarge),
child: ShimmerWidget(),
);
},
); );
} else { } else {
return Image.network( return Image.network(
@ -132,85 +146,87 @@ class StickerPackPage extends StatelessWidget {
} }
Widget _buildBody(BuildContext context, StickerPackState state) { Widget _buildBody(BuildContext context, StickerPackState state) {
return Column( return SingleChildScrollView(
children: [ child: Column(
Row( children: [
children: [ Row(
SizedBox( children: [
width: MediaQuery.of(context).size.width * 0.7, SizedBox(
child: Padding( width: MediaQuery.of(context).size.width * 0.7,
padding: const EdgeInsets.symmetric( child: Padding(
horizontal: 16, padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 16,
), vertical: 8,
child: Column( ),
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
state.stickerPack?.description ?? '', Text(
), state.stickerPack?.description ?? '',
if (state.stickerPack?.restricted ?? false) ),
Padding( if (state.stickerPack?.restricted ?? false)
padding: const EdgeInsets.only(top: 16), Padding(
child: Text( padding: const EdgeInsets.only(top: 16),
t.pages.stickerPack.restricted, child: Text(
style: const TextStyle( t.pages.stickerPack.restricted,
fontWeight: FontWeight.bold, style: const TextStyle(
fontWeight: FontWeight.bold,
),
), ),
), ),
), ],
], ),
), ),
), ),
), const Spacer(),
const Spacer(), Padding(
Padding( padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16), child: _buildButton(context, state),
child: _buildButton(context, state), ),
), ],
],
),
Padding(
padding: const EdgeInsets.only(
top: 16,
left: 8,
right: 8,
), ),
child: GridView.builder( Padding(
shrinkWrap: true, padding: const EdgeInsets.only(
physics: const NeverScrollableScrollPhysics(), top: 16,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( left: 8,
crossAxisCount: 4, right: 8,
mainAxisSpacing: 8, ),
crossAxisSpacing: 8, child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: state.stickerPack!.stickers.length,
itemBuilder: (_, index) {
final sticker = state.stickerPack!.stickers[index];
return InkWell(
child: StickerWrapper(
sticker,
cover: false,
),
onTap: () {
showDialog<void>(
context: context,
builder: (context) {
return IgnorePointer(
child: StickerWrapper(
sticker,
cover: false,
),
);
},
);
},
);
},
), ),
itemCount: state.stickerPack!.stickers.length,
itemBuilder: (_, index) {
final sticker = state.stickerPack!.stickers[index];
return InkWell(
child: StickerWrapper(
sticker,
cover: false,
),
onTap: () {
showDialog<void>(
context: context,
builder: (context) {
return IgnorePointer(
child: StickerWrapper(
sticker,
cover: false,
),
);
},
);
},
);
},
), ),
), ],
], ),
); );
} }

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

View File

@ -2,13 +2,11 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.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/constants.dart';
import 'package:moxxyv2/ui/service/data.dart'; import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/widgets/avatar.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; Widget? preview;
if (widget.conversation.lastMessage!.stickerPackId != null) { if (widget.conversation.lastMessage!.stickerPackId != null) {
if (widget.conversation.lastMessage!.fileMetadata!.path != null) { if (widget.conversation.lastMessage!.fileMetadata!.path != null) {
@ -417,17 +415,7 @@ class ConversationsListRowState extends State<ConversationsListRow> {
!widget.conversation.isTyping) !widget.conversation.isTyping)
Padding( Padding(
padding: const EdgeInsets.only(right: 5), padding: const EdgeInsets.only(right: 5),
child: child: _buildLastMessagePreview(),
BlocBuilder<StickersBloc, StickersState>(
buildWhen: (prev, next) =>
prev.stickerPacks.length !=
next.stickerPacks.length &&
widget.conversation.lastMessage
?.stickerPackId !=
null,
builder: (_, state) =>
_buildLastMessagePreview(state),
),
) )
else else
const SizedBox(height: 30), 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/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart'; import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.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. /// A wrapper data class to group by a sticker pack's id, but display its title.
@immutable @immutable
@ -36,110 +37,42 @@ class _StickerPackSeparator {
int get hashCode => name.hashCode ^ id.hashCode; int get hashCode => name.hashCode ^ id.hashCode;
} }
class StickerPicker extends StatelessWidget { class StickerPicker extends StatefulWidget {
StickerPicker({ StickerPicker({
required this.width, required this.width,
required this.onStickerTapped, required this.onStickerTapped,
super.key, super.key,
}) { }) {
_itemSize = (width - 2 * 15 - 3 * 30) / 4; itemSize = (width - 2 * 15 - 3 * 30) / 4;
} }
final double width; final double width;
late final double _itemSize;
final void Function(Sticker) onStickerTapped; final void Function(Sticker) onStickerTapped;
Widget _buildList(BuildContext context, StickersState state) { late final double itemSize;
// TODO(PapaTutuWawa): Solve this somewhere else
final stickerPacks =
state.stickerPacks.where((pack) => !pack.restricted).toList();
if (stickerPacks.isEmpty) { @override
return Padding( StickerPickerState createState() => StickerPickerState();
padding: const EdgeInsets.symmetric(horizontal: 16), }
child: Align(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${t.pages.conversation.stickerPickerNoStickersLine1}\n${t.pages.conversation.stickerPickerNoStickersLine2}',
textAlign: TextAlign.center,
),
TextButton(
onPressed: () {
context.read<nav.NavigationBloc>().add(
nav.PushedNamedEvent(
const nav.NavigationDestination(stickersRoute),
),
);
},
child: Text(t.pages.conversation.stickerSettings),
),
],
),
),
);
}
return Padding( class StickerPickerState extends State<StickerPicker> {
padding: const EdgeInsets.symmetric(horizontal: 16), final BidirectionalStickerPackController _controller =
child: GroupedListView<StickerPack, _StickerPackSeparator>( BidirectionalStickerPackController(true);
elements: stickerPacks,
groupBy: (stickerPack) => _StickerPackSeparator(
stickerPack.name,
stickerPack.id,
),
groupSeparatorBuilder: (separator) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
separator.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
),
),
),
sort: false,
indexedItemBuilder: (context, stickerPack, index) => GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: stickerPack.stickers.length,
itemBuilder: (_, index) {
return InkWell(
onTap: () {
onStickerTapped(
stickerPack.stickers[index],
);
},
onLongPress: () {
Vibrate.feedback(FeedbackType.medium);
context.read<StickerPackBloc>().add( @override
LocallyAvailableStickerPackRequested( void initState() {
stickerPack.id, super.initState();
),
); // Fetch the initial state
}, _controller.fetchOlderData();
child: Image.file( }
File(
stickerPack.stickers[index].fileMetadata.path!, @override
), void dispose() {
key: ValueKey('${stickerPack.id}_$index'), _controller.dispose();
fit: BoxFit.contain,
width: _itemSize, super.dispose();
height: _itemSize,
),
);
},
),
),
);
} }
@override @override
@ -148,7 +81,105 @@ class StickerPicker extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: _buildList(context, state), 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(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${t.pages.conversation.stickerPickerNoStickersLine1}\n${t.pages.conversation.stickerPickerNoStickersLine2}',
textAlign: TextAlign.center,
),
TextButton(
onPressed: () {
context.read<nav.NavigationBloc>().add(
nav.PushedNamedEvent(
const nav.NavigationDestination(
stickersRoute,
),
),
);
},
child: Text(t.pages.conversation.stickerSettings),
),
],
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GroupedListView<StickerPack, _StickerPackSeparator>(
controller: _controller.scrollController,
elements: snapshot.data!,
groupBy: (stickerPack) => _StickerPackSeparator(
stickerPack.name,
stickerPack.id,
),
groupSeparatorBuilder: (separator) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
separator.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
),
),
),
sort: false,
indexedItemBuilder: (context, stickerPack, index) =>
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: stickerPack.stickers.length,
itemBuilder: (_, index) {
return InkWell(
onTap: () {
widget.onStickerTapped(
stickerPack.stickers[index],
);
},
onLongPress: () {
Vibrate.feedback(FeedbackType.medium);
context.read<StickerPackBloc>().add(
LocallyAvailableStickerPackRequested(
stickerPack.id,
),
);
},
child: Image.file(
File(
stickerPack.stickers[index].fileMetadata.path!,
),
key: ValueKey('${stickerPack.id}_$index'),
fit: BoxFit.contain,
width: widget.itemSize,
height: widget.itemSize,
),
);
},
),
),
);
},
),
); );
}, },
); );