mirror of
https://github.com/ImranR98/Obtainium.git
synced 2026-07-30 17:06:26 -04:00
983 lines
31 KiB
Dart
983 lines
31 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
|
import 'package:markdown/markdown.dart' as md;
|
|
import 'package:obtainium/components/generated_form_renderer.dart';
|
|
import 'package:obtainium/main.dart';
|
|
import 'package:obtainium/pages/app.dart';
|
|
import 'package:obtainium/theme.dart';
|
|
import 'package:obtainium/components/ui_widgets.dart';
|
|
import 'package:obtainium/providers/apps_provider.dart';
|
|
import 'package:obtainium/providers/notifications_provider.dart';
|
|
import 'package:obtainium/providers/settings_provider.dart';
|
|
import 'package:obtainium/providers/source_provider.dart';
|
|
// AppsFilter and AppListBuilder are defined below in this file.
|
|
import 'package:provider/provider.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
|
|
final RegExp _changelogUrlRegEx = RegExp(
|
|
'(http|ftp|https)://([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?',
|
|
);
|
|
|
|
void showChangeLogDialog(
|
|
BuildContext context,
|
|
App app,
|
|
String? changesUrl,
|
|
AppSource appSource,
|
|
String changeLog,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return GeneratedFormModal(
|
|
title: tr('changes'),
|
|
items: const [],
|
|
message: app.latestVersion,
|
|
additionalWidgets: [
|
|
changesUrl != null
|
|
? LinkText(
|
|
text: changesUrl,
|
|
url: changesUrl,
|
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
changesUrl != null
|
|
? const SizedBox(height: 16)
|
|
: const SizedBox.shrink(),
|
|
appSource.changeLogIfAnyIsMarkDown
|
|
? MarkdownBody(
|
|
styleSheet: MarkdownStyleSheet(
|
|
blockquoteDecoration: BoxDecoration(
|
|
color: Theme.of(context).cardColor,
|
|
),
|
|
),
|
|
data: changeLog,
|
|
onTapLink: (text, href, title) {
|
|
if (href != null) {
|
|
unawaited(
|
|
launchUrlString(
|
|
href.startsWith('http://') ||
|
|
href.startsWith('https://')
|
|
? href
|
|
: '${Uri.parse(app.url).origin}/$href',
|
|
mode: LaunchMode.externalApplication,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
extensionSet: md.ExtensionSet(
|
|
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
|
|
[
|
|
md.EmojiSyntax(),
|
|
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
|
|
],
|
|
),
|
|
)
|
|
: Text(changeLog),
|
|
],
|
|
singleNullReturnButton: tr('ok'),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
VoidCallback? getChangeLogFn(BuildContext context, App app) {
|
|
String? changesUrl;
|
|
String? changeLog = app.changeLog;
|
|
final trimmedChangeLog = changeLog?.trim() ?? '';
|
|
final urlMatch = _changelogUrlRegEx.firstMatch(trimmedChangeLog);
|
|
if (urlMatch != null &&
|
|
urlMatch.start == 0 &&
|
|
urlMatch.end == trimmedChangeLog.length) {
|
|
changesUrl = trimmedChangeLog;
|
|
changeLog = null;
|
|
}
|
|
if (changeLog == null && changesUrl == null) return null;
|
|
return () {
|
|
final appSource = SourceProvider().getSource(
|
|
app.url,
|
|
overrideSource: app.overrideSource,
|
|
);
|
|
changesUrl ??= appSource.changeLogPageFromStandardUrl(app.url);
|
|
if (changeLog != null) {
|
|
showChangeLogDialog(context, app, changesUrl, appSource, changeLog);
|
|
} else if (changesUrl != null) {
|
|
unawaited(
|
|
launchUrlString(changesUrl!, mode: LaunchMode.externalApplication),
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
class AppIconWidget extends StatefulWidget {
|
|
final String appId;
|
|
final bool installed;
|
|
final AppsProvider appsProvider;
|
|
|
|
const AppIconWidget({
|
|
super.key,
|
|
required this.appId,
|
|
required this.installed,
|
|
required this.appsProvider,
|
|
});
|
|
|
|
@override
|
|
State<AppIconWidget> createState() => _AppIconWidgetState();
|
|
}
|
|
|
|
class _AppIconWidgetState extends State<AppIconWidget> {
|
|
late Future<void> _iconFuture;
|
|
String? _lastAppId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_lastAppId = widget.appId;
|
|
_iconFuture = widget.appsProvider.updateAppIcon(widget.appId);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(AppIconWidget oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.appId != _lastAppId) {
|
|
_lastAppId = widget.appId;
|
|
_iconFuture = widget.appsProvider.updateAppIcon(widget.appId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final name = widget.appsProvider.apps[widget.appId]?.name ?? '';
|
|
return Semantics(
|
|
label: name,
|
|
button: true,
|
|
onTap: widget.installed
|
|
? () => packageManager.openApp(widget.appId)
|
|
: null,
|
|
onLongPress: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
AppPage(appId: widget.appId, showOppositeOfPreferredView: true),
|
|
),
|
|
);
|
|
},
|
|
child: InkWell(
|
|
child: FutureBuilder(
|
|
future: _iconFuture,
|
|
builder: (ctx, val) => AppIcon(
|
|
bytes: widget.appsProvider.apps[widget.appId]?.icon,
|
|
size: 44,
|
|
dimmed: !widget.installed,
|
|
),
|
|
),
|
|
onDoubleTap: () {
|
|
if (widget.installed) {
|
|
packageManager.openApp(widget.appId);
|
|
}
|
|
},
|
|
onLongPress: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => AppPage(
|
|
appId: widget.appId,
|
|
showOppositeOfPreferredView: true,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AppListTile extends StatelessWidget {
|
|
final AppInMemory appInMemory;
|
|
final SettingsProvider settingsProvider;
|
|
final AppsProvider appsProvider;
|
|
|
|
final bool multiSelected;
|
|
|
|
final bool detailSelected;
|
|
final bool autofocus;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onToggleSelected;
|
|
|
|
/// Shape for the tile's selection/pinned highlight, so it matches the
|
|
/// enclosing card's (or group segment's) corners. Falls back to the theme.
|
|
final BorderRadius? borderRadius;
|
|
|
|
const AppListTile({
|
|
super.key,
|
|
required this.appInMemory,
|
|
required this.settingsProvider,
|
|
required this.appsProvider,
|
|
required this.multiSelected,
|
|
required this.detailSelected,
|
|
required this.autofocus,
|
|
required this.onTap,
|
|
required this.onToggleSelected,
|
|
this.borderRadius,
|
|
});
|
|
|
|
App get _app => appInMemory.app;
|
|
|
|
Widget _updateButton(BuildContext context) {
|
|
final trackOnly = _app.settings.getBool('trackOnly');
|
|
final cs = Theme.of(context).colorScheme;
|
|
final onPressed = appsProvider.areDownloadsRunning()
|
|
? null
|
|
: () {
|
|
settingsProvider.heavyImpact();
|
|
appsProvider
|
|
.downloadAndInstallLatestApps([
|
|
_app.id,
|
|
], appNavigatorKey.currentContext)
|
|
.then((res) {
|
|
if (res.isNotEmpty && context.mounted) {
|
|
final np = context.read<NotificationsProvider>();
|
|
np.cancel(updateNotificationId);
|
|
np.cancel(
|
|
SilentUpdateAttemptNotification(
|
|
[],
|
|
id: res[0].hashCode,
|
|
).id,
|
|
);
|
|
}
|
|
})
|
|
.catchError((e) {
|
|
if (context.mounted) showError(e, context);
|
|
});
|
|
};
|
|
return IconButton.filled(
|
|
onPressed: onPressed,
|
|
tooltip: trackOnly ? tr('markUpdated') : tr('update'),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: cs.primary,
|
|
foregroundColor: cs.onPrimary,
|
|
disabledBackgroundColor: cs.onSurface.withValues(alpha: 0.12),
|
|
disabledForegroundColor: cs.onSurface.withValues(alpha: 0.38),
|
|
visualDensity: VisualDensity.compact,
|
|
shape: RoundedSuperellipseBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
),
|
|
icon: Icon(trackOnly ? Icons.check_rounded : Icons.download_rounded),
|
|
);
|
|
}
|
|
|
|
Widget _authorText() {
|
|
return Text(
|
|
tr('byX', args: [appInMemory.author]),
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
overflow: TextOverflow.ellipsis,
|
|
fontWeight: _app.pinned ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _repoMovedRow(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final infoColor = colorScheme.primary.withValues(alpha: 0.7);
|
|
final textColor = colorScheme.onSurfaceVariant;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.info_outline, color: infoColor, size: 14),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
tr('repoRenamed'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style:
|
|
Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: textColor) ??
|
|
TextStyle(color: textColor, fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showChangesFn = getChangeLogFn(context, _app);
|
|
final hasUpdate =
|
|
_app.installedVersion != null &&
|
|
_app.installedVersion != _app.latestVersion;
|
|
final Widget trailingRow = LayoutBuilder(
|
|
builder: (context, constraints) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (hasUpdate) ...[_updateButton(context), const SizedBox(width: 8)],
|
|
_VersionLabel(
|
|
appInMemory: appInMemory,
|
|
settingsProvider: settingsProvider,
|
|
maxWidth: math.min(constraints.maxWidth / 3, 200),
|
|
showChangesFn: showChangesFn,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final disableSwipe = settingsProvider.disableSwipeActions;
|
|
|
|
final transparent = Colors.transparent.toARGB32();
|
|
final categories = _app.categories;
|
|
final List<double> stops = [
|
|
if (categories.isNotEmpty)
|
|
...List.generate(categories.length, (i) => i / categories.length),
|
|
1.0,
|
|
];
|
|
final appId = _app.id;
|
|
final installed = _app.installedVersion;
|
|
final latest = _app.latestVersion;
|
|
final trackOnly = _app.settings.getBool('trackOnly');
|
|
final canInstall = installed == null && !trackOnly;
|
|
final canUpdate = installed != null && installed != latest && !trackOnly;
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
final swipeBackground = canInstall
|
|
? Container(
|
|
color: cs.primaryContainer,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.only(left: 24),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.install_mobile, color: cs.onPrimaryContainer),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
tr('install'),
|
|
style: TextStyle(color: cs.onPrimaryContainer),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: canUpdate
|
|
? Container(
|
|
color: cs.primaryContainer,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.only(left: 24),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.system_update_alt_rounded,
|
|
color: cs.onPrimaryContainer,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
tr('update'),
|
|
style: TextStyle(color: cs.onPrimaryContainer),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null;
|
|
|
|
return ValueListenableBuilder<double?>(
|
|
valueListenable: appInMemory.downloadProgressNotifier,
|
|
builder: (context, downloadProgress, child) {
|
|
final tileChild = Semantics(
|
|
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
|
|
if (canInstall || canUpdate)
|
|
CustomSemanticsAction(
|
|
label: canUpdate ? tr('update') : tr('install'),
|
|
): () {
|
|
if (!appsProvider.areDownloadsRunning()) {
|
|
appsProvider.downloadAndInstallLatestApps([
|
|
appId,
|
|
], appNavigatorKey.currentContext);
|
|
}
|
|
},
|
|
CustomSemanticsAction(label: tr('remove')): () {
|
|
appsProvider.removeAppsWithModal(context, [_app]);
|
|
},
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: categories.isEmpty
|
|
? null
|
|
: LinearGradient(
|
|
stops: stops,
|
|
begin: const Alignment(-1, 0),
|
|
end: const Alignment(-0.97, 0),
|
|
colors: [
|
|
...categories.map(
|
|
(e) => Color(
|
|
settingsProvider.categories[e] ?? transparent,
|
|
).withAlpha(255),
|
|
),
|
|
Color(transparent),
|
|
],
|
|
),
|
|
),
|
|
child: () {
|
|
final tile = ListTile(
|
|
autofocus: autofocus,
|
|
shape: borderRadius != null
|
|
? RoundedSuperellipseBorder(borderRadius: borderRadius!)
|
|
: null,
|
|
tileColor: _app.pinned
|
|
? Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.06)
|
|
: Colors.transparent,
|
|
selectedTileColor: Theme.of(context)
|
|
.colorScheme
|
|
.primary
|
|
.withValues(alpha: _app.pinned ? 0.2 : 0.1),
|
|
selected: multiSelected || detailSelected,
|
|
leading: settingsProvider.isTV
|
|
? null
|
|
: AppIconWidget(
|
|
appId: _app.id,
|
|
installed: appInMemory.installedInfo != null,
|
|
appsProvider: appsProvider,
|
|
),
|
|
onLongPress: onToggleSelected,
|
|
title: Text(
|
|
maxLines: 1,
|
|
appInMemory.name,
|
|
style: TextStyle(
|
|
overflow: TextOverflow.ellipsis,
|
|
fontWeight: _app.pinned
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
subtitle: _app.hasPendingRepoRename
|
|
? Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_authorText(),
|
|
_repoMovedRow(context),
|
|
],
|
|
)
|
|
: _authorText(),
|
|
trailing: downloadProgress != null
|
|
? DownloadProgressTrailing(
|
|
progress: downloadProgress,
|
|
receivedBytes: appInMemory.downloadReceivedBytes,
|
|
totalBytes: appInMemory.downloadTotalBytes,
|
|
)
|
|
: trailingRow,
|
|
onTap: onTap,
|
|
);
|
|
if (settingsProvider.isTV) {
|
|
return Row(
|
|
children: [
|
|
Checkbox(
|
|
value: multiSelected,
|
|
onChanged: (_) {
|
|
onToggleSelected();
|
|
},
|
|
),
|
|
Expanded(child: tile),
|
|
],
|
|
);
|
|
}
|
|
return tile;
|
|
}(),
|
|
),
|
|
);
|
|
|
|
return disableSwipe || downloadProgress != null
|
|
? tileChild
|
|
: Dismissible(
|
|
key: ValueKey(appId),
|
|
direction: DismissDirection.horizontal,
|
|
background: swipeBackground ?? const SizedBox.shrink(),
|
|
secondaryBackground: Container(
|
|
color: cs.errorContainer,
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 24),
|
|
child: Icon(Icons.delete_outline,
|
|
color: cs.onErrorContainer),
|
|
),
|
|
confirmDismiss: (direction) async {
|
|
if (direction == DismissDirection.startToEnd) {
|
|
if ((canInstall || canUpdate) &&
|
|
!appsProvider.areDownloadsRunning()) {
|
|
settingsProvider.heavyImpact();
|
|
unawaited(
|
|
appsProvider
|
|
.downloadAndInstallLatestApps([
|
|
appId,
|
|
], appNavigatorKey.currentContext)
|
|
.catchError((e) {
|
|
if (context.mounted) showError(e, context);
|
|
return <String>[];
|
|
}),
|
|
);
|
|
}
|
|
return false;
|
|
} else {
|
|
settingsProvider.lightImpact();
|
|
return appsProvider.removeAppsWithModal(context, [_app]);
|
|
}
|
|
},
|
|
onDismissed: (direction) {},
|
|
child: tileChild,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class DownloadProgressTrailing extends StatelessWidget {
|
|
final double progress;
|
|
final int? receivedBytes;
|
|
final int? totalBytes;
|
|
final VoidCallback? onCancel;
|
|
const DownloadProgressTrailing({
|
|
super.key,
|
|
required this.progress,
|
|
this.receivedBytes,
|
|
this.totalBytes,
|
|
this.onCancel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final installing = progress < 0;
|
|
final label = installing ? tr('installing') : '${progress.toInt()}%';
|
|
final sizeLabel = installing
|
|
? null
|
|
: formatDownloadSize(receivedBytes, totalBytes);
|
|
final labelStyle =
|
|
Theme.of(context).textTheme.labelSmall?.copyWith(fontSize: 11) ??
|
|
const TextStyle(fontSize: 11);
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 112,
|
|
child: Semantics(
|
|
label: sizeLabel == null ? label : '$label $sizeLabel',
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: installing ? null : progress / 100,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: labelStyle,
|
|
),
|
|
if (sizeLabel != null)
|
|
Text(
|
|
sizeLabel,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: labelStyle,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (!installing && onCancel != null)
|
|
DownloadCancelButton(onPressed: onCancel!),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
String capitalizeFirst(String s) =>
|
|
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
|
|
|
/// A collapsible section header + its app tiles, used when the list is grouped
|
|
/// (by category or source). [title] is the already-resolved display label.
|
|
class AppListGroupSection extends StatelessWidget {
|
|
final String title;
|
|
final bool expanded;
|
|
final int appCount;
|
|
final VoidCallback onToggle;
|
|
final List<Widget> Function() buildTiles;
|
|
|
|
const AppListGroupSection({
|
|
super.key,
|
|
required this.title,
|
|
required this.expanded,
|
|
required this.appCount,
|
|
required this.onToggle,
|
|
required this.buildTiles,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final showItems = expanded && appCount > 0;
|
|
final tiles = showItems ? buildTiles() : const <Widget>[];
|
|
final segmentCount = 1 + tiles.length;
|
|
|
|
Widget segment(int i, Color color, Widget child) => ConnectedCard(
|
|
isFirst: i == 0,
|
|
isLast: i == segmentCount - 1,
|
|
color: color,
|
|
child: child,
|
|
);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
// Small gaps so entries read as distinct positional tiles, matching the
|
|
// connected-tile look used for settings.
|
|
spacing: 3,
|
|
children: [
|
|
segment(
|
|
0,
|
|
colorScheme.surfaceContainerHigh,
|
|
InkWell(
|
|
onTap: onToggle,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
AnimatedRotation(
|
|
turns: expanded ? 0.25 : 0,
|
|
duration: ExpressiveMotion.short,
|
|
child: const Icon(Icons.chevron_right_rounded),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Text(appCount.toString()),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
...tiles.asMap().entries.map(
|
|
(e) => segment(e.key + 1, colorScheme.surfaceContainerLow, e.value),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AppsFilter {
|
|
String nameFilter;
|
|
String authorFilter;
|
|
String idFilter;
|
|
bool includeUptodate;
|
|
bool includeNonInstalled;
|
|
Set<String> categoryFilter;
|
|
String sourceFilter;
|
|
|
|
AppsFilter({
|
|
this.nameFilter = '',
|
|
this.authorFilter = '',
|
|
this.idFilter = '',
|
|
this.includeUptodate = true,
|
|
this.includeNonInstalled = true,
|
|
this.categoryFilter = const {},
|
|
this.sourceFilter = '',
|
|
});
|
|
|
|
Map<String, dynamic> toFormValuesMap() {
|
|
return {
|
|
'appName': nameFilter,
|
|
'author': authorFilter,
|
|
'appId': idFilter,
|
|
'upToDateApps': includeUptodate,
|
|
'nonInstalledApps': includeNonInstalled,
|
|
'sourceFilter': sourceFilter,
|
|
};
|
|
}
|
|
|
|
void setFormValuesFromMap(Map<String, dynamic> values) {
|
|
nameFilter = values['appName'] as String? ?? '';
|
|
authorFilter = values['author'] as String? ?? '';
|
|
idFilter = values['appId'] as String? ?? '';
|
|
includeUptodate = values['upToDateApps'] as bool? ?? false;
|
|
includeNonInstalled = values['nonInstalledApps'] as bool? ?? false;
|
|
sourceFilter = values['sourceFilter'] as String? ?? '';
|
|
}
|
|
|
|
bool isIdenticalTo(AppsFilter other, SettingsProvider settingsProvider) =>
|
|
authorFilter.trim() == other.authorFilter.trim() &&
|
|
nameFilter.trim() == other.nameFilter.trim() &&
|
|
idFilter.trim() == other.idFilter.trim() &&
|
|
includeUptodate == other.includeUptodate &&
|
|
includeNonInstalled == other.includeNonInstalled &&
|
|
settingsProvider.setEqual(categoryFilter, other.categoryFilter) &&
|
|
sourceFilter.trim() == other.sourceFilter.trim();
|
|
}
|
|
|
|
class AppListBuilder {
|
|
static List<AppInMemory> filter(List<AppInMemory> apps, AppsFilter filter) {
|
|
final nameTokens = filter.nameFilter.isNotEmpty
|
|
? filter.nameFilter
|
|
.split(' ')
|
|
.where((element) => element.trim().isNotEmpty)
|
|
.toList()
|
|
: const <String>[];
|
|
final authorTokens = filter.authorFilter.isNotEmpty
|
|
? filter.authorFilter
|
|
.split(' ')
|
|
.where((element) => element.trim().isNotEmpty)
|
|
.toList()
|
|
: const <String>[];
|
|
|
|
return apps.where((app) {
|
|
if (app.app.installedVersion == app.app.latestVersion &&
|
|
!(filter.includeUptodate)) {
|
|
return false;
|
|
}
|
|
if (app.app.installedVersion == null && !(filter.includeNonInstalled)) {
|
|
return false;
|
|
}
|
|
for (var t in nameTokens) {
|
|
if (!app.name.toLowerCase().contains(t.toLowerCase())) {
|
|
return false;
|
|
}
|
|
}
|
|
for (var t in authorTokens) {
|
|
if (!app.author.toLowerCase().contains(t.toLowerCase())) {
|
|
return false;
|
|
}
|
|
}
|
|
if (filter.idFilter.isNotEmpty) {
|
|
if (!app.app.id.contains(filter.idFilter)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (filter.categoryFilter.isNotEmpty &&
|
|
filter.categoryFilter
|
|
.intersection(app.app.categories.toSet())
|
|
.isEmpty) {
|
|
return false;
|
|
}
|
|
if (filter.sourceFilter.isNotEmpty &&
|
|
app.sourceType != filter.sourceFilter) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
static List<AppInMemory> sort(
|
|
List<AppInMemory> apps,
|
|
SortColumnSettings sortColumn,
|
|
SortOrderSettings sortOrder,
|
|
) {
|
|
if (sortColumn == SortColumnSettings.added) {
|
|
final list = List<AppInMemory>.from(apps);
|
|
if (sortOrder == SortOrderSettings.descending) {
|
|
return list.reversed.toList();
|
|
}
|
|
return list;
|
|
}
|
|
|
|
final isDesc = sortOrder == SortOrderSettings.descending;
|
|
if (sortColumn == SortColumnSettings.releaseDate) {
|
|
final entries = apps.map((a) => MapEntry(a.app.releaseDate, a)).toList()
|
|
..sort((a, b) {
|
|
final aDate = a.key;
|
|
final bDate = b.key;
|
|
if (aDate == null && bDate == null) return 0;
|
|
if (aDate == null) return 1;
|
|
if (bDate == null) return -1;
|
|
return isDesc ? bDate.compareTo(aDate) : aDate.compareTo(bDate);
|
|
});
|
|
apps = entries.map((e) => e.value).toList();
|
|
} else {
|
|
String keyFn(AppInMemory a) => switch (sortColumn) {
|
|
SortColumnSettings.authorName => (a.author + a.name).toLowerCase(),
|
|
SortColumnSettings.nameAuthor => (a.name + a.author).toLowerCase(),
|
|
_ => '',
|
|
};
|
|
final entries = apps.map((a) => MapEntry(keyFn(a), a)).toList()
|
|
..sort((a, b) => a.key.compareTo(b.key));
|
|
apps = entries.map((e) => e.value).toList();
|
|
if (isDesc) {
|
|
apps = apps.reversed.toList();
|
|
}
|
|
}
|
|
return apps;
|
|
}
|
|
|
|
static List<AppInMemory> reorder(
|
|
List<AppInMemory> apps,
|
|
bool pinUpdates,
|
|
bool buryNonInstalled,
|
|
Set<String> existingUpdates,
|
|
) {
|
|
if (pinUpdates) {
|
|
final temp = <AppInMemory>[];
|
|
apps = apps.where((sa) {
|
|
if (existingUpdates.contains(sa.app.id)) {
|
|
temp.add(sa);
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
apps = [...temp, ...apps];
|
|
}
|
|
|
|
if (buryNonInstalled) {
|
|
final temp = <AppInMemory>[];
|
|
apps = apps.where((sa) {
|
|
if (sa.app.installedVersion == null) {
|
|
temp.add(sa);
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
apps = [...apps, ...temp];
|
|
}
|
|
|
|
final tempRenamed = <AppInMemory>[];
|
|
final tempPinned = <AppInMemory>[];
|
|
final tempNotPinned = <AppInMemory>[];
|
|
for (var a in apps) {
|
|
if (a.app.hasPendingRepoRename) {
|
|
tempRenamed.add(a);
|
|
} else if (a.app.pinned) {
|
|
tempPinned.add(a);
|
|
} else {
|
|
tempNotPinned.add(a);
|
|
}
|
|
}
|
|
apps = [...tempRenamed, ...tempPinned, ...tempNotPinned];
|
|
|
|
return apps;
|
|
}
|
|
}
|
|
|
|
class _VersionLabel extends StatelessWidget {
|
|
final AppInMemory appInMemory;
|
|
final SettingsProvider settingsProvider;
|
|
final double maxWidth;
|
|
final VoidCallback? showChangesFn;
|
|
|
|
const _VersionLabel({
|
|
required this.appInMemory,
|
|
required this.settingsProvider,
|
|
required this.maxWidth,
|
|
required this.showChangesFn,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final app = appInMemory.app;
|
|
final hasUpdate =
|
|
app.installedVersion != null &&
|
|
app.installedVersion != app.latestVersion;
|
|
final updateColor = hasUpdate
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.onSurfaceVariant;
|
|
final highlight = settingsProvider.highlightTouchTargets;
|
|
|
|
Widget content = Padding(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Container(
|
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
child: DefaultTextStyle.merge(
|
|
style: const TextStyle(fontSize: 14),
|
|
child: Text(
|
|
installedVersionText(app),
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.end,
|
|
style: TextStyle(
|
|
fontStyle:
|
|
isVersionPseudo(app) ? FontStyle.italic : null,
|
|
color: updateColor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
changesLabel(app, showChangesFn != null),
|
|
style: TextStyle(
|
|
fontStyle: FontStyle.italic,
|
|
color: updateColor,
|
|
fontSize: 13,
|
|
decoration: showChangesFn == null
|
|
? TextDecoration.none
|
|
: TextDecoration.underline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (showChangesFn == null) return content;
|
|
|
|
if (highlight) {
|
|
content = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
child: content,
|
|
),
|
|
);
|
|
}
|
|
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: showChangesFn,
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
String installedVersionText(App app) {
|
|
final installed = app.installedVersion;
|
|
final latest = app.latestVersion;
|
|
if (installed != null && installed != latest) {
|
|
return '$installed → $latest';
|
|
}
|
|
return installed ?? tr('notInstalled');
|
|
}
|
|
|
|
String changesLabel(App app, bool hasChangeLogFn) {
|
|
return app.releaseDate == null
|
|
? hasChangeLogFn
|
|
? tr('changes')
|
|
: ''
|
|
: DateFormat('yyyy-MM-dd').format(app.releaseDate!.toLocal());
|
|
}
|
|
}
|