File: ApplicationPage.qml

package info (click to toggle)
plasma-discover 6.3.6-1
  • links: PTS, VCS
  • area: main
  • in suites: trixie
  • size: 14,128 kB
  • sloc: cpp: 29,350; xml: 2,302; python: 45; sh: 5; makefile: 5
file content (988 lines) | stat: -rw-r--r-- 42,547 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
/*
 *   SPDX-FileCopyrightText: 2012 Aleix Pol Gonzalez <aleixpol@blue-systems.com>
 *   SPDX-FileCopyrightText: 2022 Nate Graham <nate@kde.org>
 *   SPDX-FileCopyrightText: 2023 ivan tkachenko <me@ratijas.tk>
 *
 *   SPDX-License-Identifier: LGPL-2.0-or-later
 */

pragma ComponentBehavior: Bound

import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.discover as Discover
import org.kde.discover.app as DiscoverApp
import org.kde.kirigami as Kirigami
import org.kde.purpose as Purpose

DiscoverPage {
    id: appInfo

    title: "" // It would duplicate the text in the header right below it
    clip: true

    required property Discover.AbstractResource application

    readonly property int visibleReviews: 3
    readonly property int internalSpacings: padding * 2
    readonly property bool availableFromOnlySingleSource: !originsMenuAction.visible

    // Usually this page is not the top level page, but when we are, isHome being
    // true will ensure that the search field suggests we are searching in the list
    // of available apps, not inside the app page itself. This will happen when
    // Discover is launched e.g. from krunner or otherwise requested to show a
    // specific application on launch.
    readonly property bool isHome: true

    readonly property bool isOfflineUpgrade: application.packageName === "discover-offline-upgrade"

    readonly property bool isTechnicalPackage: application.type == Discover.AbstractResource.ApplicationSupport
                                            || application.type == Discover.AbstractResource.System

    readonly property int smallButtonSize: Kirigami.Units.iconSizes.small + (Kirigami.Units.smallSpacing * 2)

    function colorForLicenseType(licenseType: string): string {
        switch(licenseType) {
            case "free":
                return Kirigami.Theme.positiveTextColor;
            case "non-free":
                return Kirigami.Theme.neutralTextColor;
            case "proprietary":
                return Kirigami.Theme.negativeTextColor
            case "unknown":
            default:
                return Kirigami.Theme.neutralTextColor;
        }
    }

    function explanationForLicenseType(licenseType: string): string {
        let freeSoftwareUrl = "https://www.gnu.org/philosophy/free-sw.html"
        let fsfUrl = "https://www.fsf.org/"
        let osiUrl = "https://opensource.org/"
        let proprietarySoftwareUrl = "https://www.gnu.org/proprietary"
        let hasHomepageUrl = application.homepage.toString().length > 0

        switch(licenseType) {
            case "proprietary":
                if (hasHomepageUrl) {
                    return xi18nc("@info", "Only install %1 if you fully trust its authors because it is <emphasis strong='true'>proprietary</emphasis>: Your freedom to use, modify, and redistribute this application is restricted, and its source code is partially or entirely closed to public inspection and improvement. This means third parties and users like you cannot verify its operation, security, and trustworthiness.<nl/><nl/>The application may be perfectly safe to use, or it may be acting against you in various ways — such as harvesting your personal information, tracking your location, or transmitting the contents of your data to someone else. Only use it if you fully trust its authors. More information may be available on <link url='%2'>the application's website</link>.<nl/><nl/>Learn more at <link url='%3'>%3</link>.",
                                appInfo.application.name,
                                appInfo.application.homepage.toString(),
                                proprietarySoftwareUrl)
                } else {
                    return xi18nc("@info", "Only install %1 if you fully trust its authors because it is <emphasis strong='true'>proprietary</emphasis>: Your freedom to use, modify, and redistribute this application is restricted, and its source code is partially or entirely closed to public inspection and improvement. This means third parties and users like you cannot verify its operation, security, and trustworthiness.<nl/><nl/>The application may be perfectly safe to use, or it may be acting against you in various ways — such as harvesting your personal information, tracking your location, or transmitting the contents of your data to someone else. Only use it if you fully trust its authors. Learn more at <link url='%2'>%2</link>.",
                                  appInfo.application.name,
                                  proprietarySoftwareUrl)
                }

            case "non-free":
                if (hasHomepageUrl) {
                    return xi18nc("@info", "%1 uses one or more licenses not certified as “Free Software” by either the <link url='%2'>Free Software Foundation</link> or the <link url='%3'>Open Source Initiative</link>. This means your freedom to use, study, modify, and share it may be restricted in some ways.<nl/><nl/>Make sure to read the license text and understand any restrictions before using the software.<nl/><nl/>If the license does not even grant access to read the source code, make sure you fully trust the authors, as no one else can verify the trustworthiness and security of its code to ensure that it is not acting against you in hidden ways. More information may be available on <link url='%4'>the application's website</link>.<nl/><nl/>Learn more at <link url='%5'>%5</link>.",
                                appInfo.application.name,
                                fsfUrl,
                                osiUrl,
                                appInfo.application.homepage.toString(),
                                freeSoftwareUrl);
                } else {
                    return xi18nc("@info", "%1 uses one or more licenses not certified as “Free Software” by either the <link url='%2'>Free Software Foundation</link> or the <link url='%3'>Open Source Initiative</link>. This means your freedom to use, study, modify, and share it may be restricted in some ways.<nl/><nl/>Make sure to read the license text and understand any restrictions before using the software.<nl/><nl/>If the license does not even grant access to read the source code, make sure you fully trust the authors, as no one else can verify the trustworthiness and security of its code to ensure that it is not acting against you in hidden ways.<nl/><nl/>Learn more at <link url='%4'>%4</link>.",
                                  appInfo.application.name,
                                  fsfUrl,
                                  osiUrl,
                                  freeSoftwareUrl);
                }

            case "unknown":
                if (hasHomepageUrl) {
                    return xi18nc("@info", "%1 does not indicate under which license it is distributed. You may be able to determine this on <link url='%2'>the application's website</link>. Find it there or contact the author if you want to use this application for anything other than private personal use.",
                                 appInfo.application.name,
                                 appInfo.application.homepage.toString());
                } else {
                    return i18nc("@info", "%1 does not indicate under which license it is distributed. Contact the application's author if you want to use it for anything other than private personal use.",
                                 appInfo.application.name);
                }

            case "free":
            default:
                return "";
        }
    }

    ReviewsPage {
        id: reviewsSheet
        parent: appInfo.QQC2.Overlay.overlay
        model: Discover.ReviewsModel {
            id: reviewsModel
            resource: appInfo.application
            preferredSortRole: reviewsSheet.sortRole
        }
        Component.onCompleted: reviewsSheet.sortRole = reviewsModel.preferredSortRole
    }

    actions: [
        addonsAction,
        shareAction,
        appbutton.isActive ? appbutton.cancelAction : appbutton.action,
        invokeAction,
        originsMenuAction
    ]

    QQC2.ActionGroup {
        id: sourcesGroup
        exclusive: true
    }

    Kirigami.Action {
        id: shareAction
        text: i18nc("@action:button share a link to this app", "Share")
        icon.name: "document-share"
        visible: application.url.toString().length > 0 && !appInfo.isTechnicalPackage
        onTriggered: shareSheet.open()
    }

    Kirigami.Action {
        id: addonsAction
        text: i18nc("@action:button", "Add-ons")
        icon.name: "extension-symbolic"
        visible: addonsView.containsAddons
        onTriggered: {
            if (addonsView.addonsCount === 0) {
                Navigation.openExtends(application.appstreamId, appInfo.application.name)
            } else {
                addonsView.visible = true
            }
        }
    }

    // Multi-source origin display and switcher
    Kirigami.Action {
        id: originsMenuAction

        text: i18nc("@item:inlistbox %1 is the name of an app source e.g. \"Flathub\" or \"Ubuntu\"", "From %1", appInfo.application.displayOrigin)
        visible: children.length > 1
        children: sourcesGroup.actions
    }

    Instantiator {
        // alternativeResourcesModel
        model: Discover.ResourcesProxyModel {
            allBackends: true
            resourcesUrl: appInfo.application.url
        }
        delegate: QQC2.Action {
            required property var model

            QQC2.ActionGroup.group: sourcesGroup
            text: model.availableVersion
                ? i18n("%1 - %2", model.displayOrigin, model.availableVersion)
                : model.displayOrigin
            icon.name: model.sourceIcon
            checkable: true
            checked: appInfo.application === model.application
            onTriggered: {
                appInfo.application = model.application
            }
        }
    }

    Kirigami.Action {
        id: invokeAction
        visible: application.isInstalled && application.canExecute && !appbutton.isActive
        text: application.executeLabel
        icon.name: "media-playback-start-symbolic"
        onTriggered: application.invokeApplication()
    }

    InstallApplicationButton {
        id: appbutton
        Layout.rightMargin: Kirigami.Units.smallSpacing
        application: appInfo.application
        visible: false
        availableFromOnlySingleSource: appInfo.availableFromOnlySingleSource
    }

    Kirigami.ImageColors {
        id: appImageColorExtractor
        source: appInfo.application.icon
    }

    Kirigami.PromptDialog {
        id: shareSheet
        parent: applicationWindow().overlay
        implicitWidth: Kirigami.Units.gridUnit * 20
        title: i18nc("@title:window", "Share Link to Application")
        standardButtons: QQC2.Dialog.NoButton

        Purpose.AlternativesView {
            id: alts
            Layout.fillWidth: true
            pluginType: "ShareUrl"
            inputData: {
                "urls": [ application.url.toString() ],
                "title": i18nc("The subject line for an email. %1 is the name of an application", "Check out the %1 app!", application.name)
            }
            onFinished: {
                shareSheet.close()
                if (error !== 0) {
                    console.error("job finished with error", error, message)
                }
                alts.reset()
            }
        }
    }

    // Scrollable page content
    ColumnLayout {
        id: pageLayout

        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
        }
        spacing: appInfo.internalSpacings

        // Colored header with app icon, name, and metadata
        Rectangle {
            Layout.fillWidth: true

            // Undo page paddings so that the header touches the edges. We don't
            // want it to actually be in the header: area since then it wouldn't
            // scroll away, which we do want.
            Layout.topMargin: -appInfo.topPadding
            Layout.leftMargin: -appInfo.leftPadding
            Layout.rightMargin: -appInfo.rightPadding

            implicitHeight: headerLayout.implicitHeight + (headerLayout.anchors.topMargin * 2)
            color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, appImageColorExtractor.dominant, 0.1)

            GridLayout {
                id: headerLayout

                readonly property bool hasMetadata: appMetadataLayout.visible
                readonly property int effectiveBasicInfoWidth: appBasicInfoLayout.implicitWidth
                readonly property int effectiveMetadataWidth: hasMetadata ? appMetadataLayout.implicitWidth + columnSpacing : 0
                readonly property int effectiveSpaceAvailable: pageLayout.width - anchors.leftMargin - anchors.rightMargin

                readonly property bool stackedMode: hasMetadata && (effectiveBasicInfoWidth + effectiveMetadataWidth > effectiveSpaceAvailable)

                columns: stackedMode || !hasMetadata ? 1 : 2
                rows: stackedMode ? 2 : 1
                columnSpacing: 0
                rowSpacing: appInfo.padding

                anchors {
                    top: parent.top
                    topMargin: appInfo.padding
                    left: parent.left
                    leftMargin: appInfo.padding
                    right: parent.right
                    rightMargin: appInfo.padding
                }


                // App icon, name, author, and rating
                RowLayout {
                    id: appBasicInfoLayout
                    Layout.maximumWidth: headerLayout.implicitWidth
                    Layout.alignment: headerLayout.stackedMode ? Qt.AlignHCenter : Qt.AlignLeft
                    spacing: appInfo.padding

                    // App icon
                    Kirigami.Icon {
                        implicitWidth: Kirigami.Units.iconSizes.huge
                        implicitHeight: Kirigami.Units.iconSizes.huge
                        source: appInfo.application.icon
                    }

                    // App name, author, and rating
                    ColumnLayout {

                        spacing: 0

                        // App name
                        Kirigami.Heading {
                            Layout.fillWidth: true
                            text: appInfo.application.name
                            type: Kirigami.Heading.Type.Primary
                            wrapMode: Text.Wrap
                            maximumLineCount: 5
                            elide: Text.ElideRight
                        }

                        // Author (for apps) or upgrade info (for offline upgrades). Verification check
                        RowLayout {
                            Layout.fillWidth: true

                            QQC2.Label {
                                id: author

                                visible: text.length > 0

                                text: {
                                    if (appInfo.isOfflineUpgrade) {
                                        return appInfo.application.upgradeText.length > 0 ? appInfo.application.upgradeText : "";
                                    } else if (appInfo.application.author.length > 0) {
                                        return appInfo.application.author;
                                    } else {
                                        return i18n("Unknown author");
                                    }
                                }
                                wrapMode: Text.Wrap
                                maximumLineCount: 5
                                elide: Text.ElideRight
                            }

                            Kirigami.Icon {
                                visible: verifiedTooltip.QQC2.ToolTip.text.length > 0
                                source: appInfo.application.verifiedIconName
                                Layout.maximumHeight: author.contentHeight
                                Layout.fillHeight: true

                                QQC2.Control {
                                    id: verifiedTooltip
                                    anchors.fill: parent

                                    QQC2.ToolTip.text: appInfo.application.verifiedMessage
                                    QQC2.ToolTip.visible: (Kirigami.Settings.tabletMode ? pressed : hovered) && QQC2.ToolTip.text !== ""
                                    QQC2.ToolTip.delay: Kirigami.Settings.tabletMode ? Qt.styleHints.mousePressAndHoldInterval : Kirigami.Units.toolTipDelay
                                }
                            }
                        }

                        // Rating
                        RowLayout {
                            visible: !appInfo.isTechnicalPackage

                            Rating {
                                value: appInfo.application.rating.sortableRating
                                starSize: author.font.pointSize
                                precision: Rating.Precision.HalfStar
                            }

                            QQC2.Label {
                                text: appInfo.application.rating ? i18np("%1 rating", "%1 ratings", appInfo.application.rating.ratingCount) : i18n("No ratings yet")
                            }
                        }
                    }
                }

                // Metadata
                // Not using Kirigami.FormLayout here because we never want it to move into Mobile
                // mode and we also want to customize the spacing, neither of which it lets us do
                GridLayout {
                    id: appMetadataLayout

                    Layout.alignment: headerLayout.stackedMode ? Qt.AlignHCenter : Qt.AlignRight

                    columns: 2
                    rows: Math.ceil(appMetadataLayout.visibleChildren.count / 2)
                    columnSpacing: Kirigami.Units.smallSpacing
                    rowSpacing: 0

                    // Not relevant to offline updates
                    visible: !appInfo.isOfflineUpgrade

                    // Version
                    QQC2.Label {
                        text: i18n("Version:")
                        Layout.alignment: Qt.AlignRight
                    }
                    QQC2.Label {
                        text: appInfo.application.versionString
                        wrapMode: Text.Wrap
                        maximumLineCount: 3
                        elide: Text.ElideRight
                    }

                    // Size
                    QQC2.Label {
                        text: i18n("Size:")
                        Layout.alignment: Qt.AlignRight
                    }
                    QQC2.Label {
                        text: appInfo.application.sizeDescription
                        wrapMode: Text.Wrap
                        maximumLineCount: 3
                        elide: Text.ElideRight
                    }

                    // Licenses
                    QQC2.Label {
                        text: i18np("License:", "Licenses:", appInfo.application.licenses.length)
                        Layout.alignment: Qt.AlignRight
                    }
                    RowLayout {
                        id: licenseRowLayout
                        spacing: Kirigami.Units.smallSpacing

                        readonly property string infoButtonToolTipText: i18nc("@info:tooltip for button opening license type description", "What does this mean?")

                        QQC2.Label {
                            visible : appInfo.application.licenses.length === 0
                            text: i18nc("The app does not provide any licenses", "Unknown")
                            wrapMode: Text.Wrap
                            elide: Text.ElideRight
                            color: appInfo.colorForLicenseType("unknown")
                        }


                        // Button to open the license details dialog if license is empty
                        QQC2.Button {
                            Layout.preferredWidth: appInfo.smallButtonSize
                            Layout.preferredHeight: appInfo.smallButtonSize
                            visible : appInfo.application.licenses.length === 0
                            icon.name: "help-contextual"
                            onClicked: licenseDetailsDialog.openWithLicenseType("unknown");

                            QQC2.ToolTip {
                                text: licenseRowLayout.infoButtonToolTipText
                            }
                        }

                        Repeater {
                            visible: appInfo.application.licenses.length > 0
                            model: appInfo.application.licenses.slice(0, 2)
                            delegate: RowLayout {
                                id: delegate

                                required property var modelData
                                required property int index

                                spacing: Kirigami.Units.smallSpacing

                                RowLayout {
                                    spacing: 0
                                    Kirigami.UrlButton {
                                        // Override some things to keep the right appearance for non-free licenses with no URL.
                                        readonly property bool hasUrl: url !== ""
                                        enabled: true
                                        font.underline: hasUrl
                                        acceptedButtons: hasUrl ? Qt.LeftButton : Qt.NoButton
                                        mouseArea.cursorShape: hasUrl ? Qt.PointingHandCursor : undefined

                                        text: delegate.modelData.name
                                        url: delegate.modelData.url
                                        horizontalAlignment: Text.AlignHCenter
                                        verticalAlignment: Text.AlignTop
                                        wrapMode: Text.Wrap
                                        maximumLineCount: 3
                                        elide: Text.ElideRight
                                        color: appInfo.colorForLicenseType(delegate.modelData.licenseType)
                                    }
                                    QQC2.Label {
                                        readonly property int licensesCount: appInfo.application.licenses.length
                                        text: i18nc("Separator between license labels e.g. 'GPL-3.0, Proprietary'", ",")
                                        visible: delegate.index <= (licensesCount - 2)
                                    }
                                }

                                // Button to open the license details dialog
                                QQC2.Button {
                                    Layout.preferredWidth: appInfo.smallButtonSize
                                    Layout.preferredHeight: appInfo.smallButtonSize
                                    visible: delegate.modelData.licenseType === "unknown"
                                          || delegate.modelData.licenseType === "non-free"
                                          || delegate.modelData.licenseType === "proprietary"
                                    icon.name: "help-contextual"
                                    onClicked: licenseDetailsDialog.openWithLicenseType(delegate.modelData.licenseType);

                                    QQC2.ToolTip {
                                        text: i18n(licenseRowLayout.infoButtonToolTipText)
                                    }
                                }
                            }
                        }

                        // "See More licenses" link, in case there are a lot of them
                        Kirigami.LinkButton {
                            visible: application.licenses.length > 3
                            text: i18np("See more…", "See more…", appInfo.application.licenses.length)
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignTop
                            elide: Text.ElideRight
                            onClicked: allLicensesSheet.open();
                        }
                    }

                    // Content Rating
                    QQC2.Label {
                        Layout.alignment: Qt.AlignRight
                        visible: !appInfo.isTechnicalPackage
                        text: i18nc("@label The app is suitable for people of the following ages or older", "Ages:")
                    }
                    RowLayout {
                        spacing: Kirigami.Units.smallSpacing
                        visible: !appInfo.isTechnicalPackage

                        QQC2.Label {
                            text: application.contentRatingMinimumAge === 0
                                ? i18nc("@item As in, the app is suitable for everyone", "Everyone")
                                : i18nc("@item %1 is a person's age in number of years",
                                        "%1+", appInfo.application.contentRatingMinimumAge)
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignTop
                            wrapMode: Text.Wrap
                            maximumLineCount: 3
                            elide: Text.ElideRight
                        }

                        // Button to open the content rating details dialog
                        QQC2.Button {
                            Layout.preferredWidth: appInfo.smallButtonSize
                            Layout.preferredHeight: appInfo.smallButtonSize
                            visible: appInfo.application.contentRatingDescription.length > 0
                            icon.name: "help-contextual"
                            text: i18n("See details")
                            display: QQC2.AbstractButton.IconOnly
                            onClicked: contentRatingDialog.open()

                            QQC2.ToolTip {
                                text: parent.text
                            }
                        }
                    }
                }
            }

            Kirigami.Separator {
                width: parent.width
                anchors.top: parent.bottom
            }
        }

        // Screenshots
        Kirigami.PlaceholderMessage {
            Layout.fillWidth: true

            visible: carousel.hasFailed
            icon.name: "image-missing"
            text: i18nc("@info placeholder message", "Screenshots not available for %1", appInfo.application.name)
        }

        CarouselInlineView {
            id: carousel

            Layout.fillWidth: true
            // Undo page paddings so that the header touches the edges. We don't
            // want it to actually be in the header: area since then it wouldn't
            // scroll away, which we do want.
            Layout.leftMargin: -appInfo.leftPadding
            Layout.rightMargin: -appInfo.rightPadding
            // This roughly replicates scaling formula for the screenshots
            // gallery on FlatHub website, adjusted to scale with gridUnit
            Layout.minimumHeight: Math.round((16 + 1/9) * Kirigami.Units.gridUnit)
            Layout.maximumHeight: 30 * Kirigami.Units.gridUnit
            Layout.preferredHeight: Math.round(width / 2) + Math.round((2 + 7/9) * Kirigami.Units.gridUnit)

            edgeMargin: appInfo.padding
            visible: carouselModel.count > 0 && !hasFailed && !appInfo.isTechnicalPackage

            carouselModel: Discover.ScreenshotsModel {
                application: appInfo.application
            }
        }

        ColumnLayout {
            id: topObjectsLayout

            // InlineMessage components are supposed to manage their spacing
            // internally. However, at least for now they require some
            // assistance from outside to stack them one after another.
            spacing: 0

            Layout.fillWidth: true

            // Cancel out parent layout's spacing, making this component effectively zero-sized when empty.
            // When non-empty, the very first top margin is provided by this layout, but bottom margins
            // are implemented by Loaders that have visible loaded items.
            Layout.topMargin: hasActiveObjects ? 0 : -pageLayout.spacing
            Layout.bottomMargin: -pageLayout.spacing

            property bool hasActiveObjects: false
            visible: hasActiveObjects

            function bindVisibility() {
                hasActiveObjects = Qt.binding(() => {
                    for (let i = 0; i < topObjectsRepeater.count; i++) {
                        const loader = topObjectsRepeater.itemAt(i);
                        const item = loader.item;
                        if (item?.Discover.Activatable.active) {
                            return true;
                        }
                    }
                    return false;
                });
            }

            Timer {
                id: bindActiveTimer

                running: false
                repeat: false
                interval: 0

                onTriggered: topObjectsLayout.bindVisibility()
            }

            Repeater {
                id: topObjectsRepeater

                model: appInfo.application.topObjects

                delegate: Loader {
                    id: topObject
                    required property string modelData

                    Layout.fillWidth: item?.Layout.fillWidth ?? false
                    Layout.topMargin: 0
                    Layout.bottomMargin: item?.Discover.Activatable.active ? appInfo.padding : 0
                    Layout.preferredHeight: item?.Discover.Activatable.active ? item.implicitHeight : 0

                    onModelDataChanged: {
                        setSource(modelData, { resource: Qt.binding(() => appInfo.application) });
                    }
                    Connections {
                        target: topObject.item?.Discover.Activatable
                        function onActiveChanged() {
                            bindActiveTimer.start();
                        }
                    }
                }
                onItemAdded: (index, item) => {
                    bindActiveTimer.start();
                }
                onItemRemoved: (index, item) => {
                    bindActiveTimer.start();
                }
            }
        }

        // App description section
        ColumnLayout {
            spacing: Kirigami.Units.smallSpacing

            // Short description
            // Not using Kirigami.Heading here because that component doesn't
            // support selectable text, and we want this to be selectable because
            // it's also used to show the path for local packages, and that makes
            // sense to be selectable
            Kirigami.SelectableLabel {
                Layout.fillWidth: true
                Layout.preferredWidth: contentWidth
                // Not relevant to the offline upgrade use case because we
                // display the info in the header instead
                visible: !appInfo.isOfflineUpgrade
                text: appInfo.application.comment
                wrapMode: Text.Wrap

                // Match `level: 1` in Kirigami.Heading
                font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.35
                font.weight: Font.DemiBold

                Accessible.role: Accessible.Heading
            }

            // Long app description
            Kirigami.SelectableLabel {
                objectName: "applicationDescription" // for appium tests
                Layout.fillWidth: true
                Layout.preferredWidth: contentWidth
                wrapMode: Text.WordWrap
                text: appInfo.application.longDescription
                textFormat: TextEdit.RichText
                onLinkActivated: link => Qt.openUrlExternally(link);
            }
        }

        // Changelog section
        ColumnLayout {
            spacing: Kirigami.Units.smallSpacing
            visible: changelogLabel.visible

            Kirigami.Heading {
                text: i18n("What's New")
                level: 2
                type: Kirigami.Heading.Type.Primary
                wrapMode: Text.Wrap
            }

            // Changelog text
            QQC2.Label {
                id: changelogLabel

                Layout.fillWidth: true

                // Some backends are known to produce empty line break as a text
                visible: text !== "" && text !== "<br />"
                wrapMode: Text.WordWrap

                Component.onCompleted: appInfo.application.fetchChangelog()
                Connections {
                    target: appInfo.application
                    function onChangelogFetched(changelog) {
                        changelogLabel.text = changelog
                    }
                }
            }
        }

        // Reviews section
        ColumnLayout {
            spacing: Kirigami.Units.smallSpacing
            visible: (reviewsSheet.sortModel.count > 0 || reviewsModel.fetching || reviewsError.hasError)
                     && !appInfo.isTechnicalPackage

            Kirigami.Heading {
                Layout.fillWidth: true
                text: i18n("Reviews")
                level: 2
                type: Kirigami.Heading.Type.Primary
                wrapMode: Text.Wrap
            }

            Kirigami.LoadingPlaceholder {
                id: reviewsLoadingPlaceholder
                Layout.alignment: Qt.AlignHCenter
                Layout.maximumWidth: Kirigami.Units.gridUnit * 15
                visible: reviewsModel.fetching
                text: i18n("Loading reviews for %1", appInfo.application.name)
            }

            Kirigami.PlaceholderMessage {
                id: reviewsError
                Layout.fillWidth: true
                readonly property bool hasError: reviewsModel.backend && reviewsModel.backend.errorMessage.length > 0 && text.length > 0 && reviewsModel.count === 0 && !reviewsLoadingPlaceholder.visible
                visible: hasError
                icon.name: "text-unflow"
                text: i18nc("@info placeholder message", "Reviews for %1 are temporarily unavailable", appInfo.application.name)
                explanation: reviewsModel.backend ? reviewsModel.backend.errorMessage : ""
            }

            ReviewsStats {
                visible: reviewsModel.count > 3
                Layout.fillWidth: true
                application: appInfo.application
                reviewsModel: reviewsModel
                model: reviewsSheet.model
                visibleReviews: appInfo.visibleReviews
                compact: appInfo.compact
            }

            // Review-related buttons
            Flow {
                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing

                QQC2.Button {
                    visible: reviewsModel.count > visibleReviews

                    text: i18nc("@action:button", "Show All Reviews")
                    icon.name: "view-visible"

                    onClicked: {
                        reviewsSheet.open()
                    }
                }

                QQC2.Button {
                    visible: appbutton.isStateAvailable && reviewsModel.backend && !reviewsError.visible && reviewsModel.backend.isResourceSupported(appInfo.application)
                    enabled: appInfo.application.isInstalled

                    text: appInfo.application.isInstalled ? i18n("Write a Review") : i18n("Install to Write a Review")
                    icon.name: "document-edit"

                    onClicked: {
                        reviewsSheet.openReviewDialog()
                    }
                }
            }
        }

        // "External Links" section
        ColumnLayout {
            readonly property int visibleButtons: (helpButton.visible ? 1 : 0)
                                                + (homepageButton.visible ? 1: 0)
                                                + (donateButton.visible ? 1 : 0)
                                                + (bugButton.visible ? 1 : 0)
                                                + (contributeButton.visible ? 1 : 0)
            visible: visibleButtons > 0 && !appInfo.isTechnicalPackage

            spacing: Kirigami.Units.smallSpacing

            Kirigami.Heading {
                text: i18nc("@title", "External Links")
                level: 2
                type: Kirigami.Heading.Type.Primary
                wrapMode: Text.Wrap
            }

            ColumnLayout {
                Layout.fillWidth: true

                spacing: Kirigami.Units.largeSpacing

                ApplicationResourceButton {
                    id: helpButton

                    visible: website.length > 0

                    icon: "documentation-symbolic"
                    website: application.helpURL.toString()
                    linkText: i18nc("@info text of a web URL", "Read the documentation")
                }

                ApplicationResourceButton {
                    id: homepageButton


                    visible: website.length > 0

                    icon: "internet-services-symbolic"
                    website: application.homepage.toString()
                    linkText: i18nc("@info text of a web URL", "Visit the project's website")
                }

                ApplicationResourceButton {
                    id: donateButton

                    visible: website.length > 0

                    icon: "help-donate-symbolic"
                    website: application.donationURL.toString()
                    linkText: i18nc("@info text of a web URL", "Donate to the project")
                }

                ApplicationResourceButton {
                    id: bugButton

                    visible: website.length > 0

                    icon: "tools-report-bug-symbolic"
                    website: application.bugURL.toString()
                    linkText: i18nc("@info text of a web URL", "Report a bug")
                }

                ApplicationResourceButton {
                    id: contributeButton

                    visible: website.length > 0

                    icon: "applications-development-symbolic"
                    website: application.contributeURL.toString()
                    linkText: i18nc("@info text of a web URL", "Start contributing")
                }
            }
        }

        Repeater {
            model: appInfo.application.bottomObjects

            delegate: Loader {
                required property string modelData

                Layout.fillWidth: true

                onModelDataChanged: {
                    setSource(modelData, { resource: Qt.binding(() => appInfo.application) });
                }
            }
        }
    }

    AddonsView {
        id: addonsView

        application: appInfo.application
        parent: appInfo.QQC2.Overlay.overlay
    }

    Kirigami.Dialog {
        id: allLicensesSheet
        title: i18n("All Licenses")
        standardButtons: Kirigami.Dialog.NoButton
        preferredWidth: Kirigami.Units.gridUnit * 16
        maximumHeight: Kirigami.Units.gridUnit * 20

        ColumnLayout {
            spacing: 0

            Repeater {
                model: appInfo.application.licenses

                delegate: QQC2.ItemDelegate {
                    background: null
                    id: delegate

                    required property var modelData

                    contentItem: Kirigami.UrlButton {
                        // Override some things to keep the right appearance for non-free licenses with no URL.
                        readonly property bool hasUrl: url !== ""
                        enabled: true
                        font.underline: hasUrl
                        acceptedButtons: hasUrl ? Qt.LeftButton : Qt.NoButton
                        mouseArea.cursorShape: hasUrl ? Qt.PointingHandCursor : undefined

                        text: delegate.modelData.name
                        url: delegate.modelData.url
                        horizontalAlignment: Text.AlignLeft
                        color: appInfo.colorForLicenseType(delegate.modelData.licenseType)
                    }
                }
            }
        }
    }

    Kirigami.PromptDialog {
        id: contentRatingDialog
        parent: appInfo.QQC2.Overlay.overlay
        title: i18n("Content Rating")
        preferredWidth: Kirigami.Units.gridUnit * 25
        standardButtons: Kirigami.Dialog.NoButton

        QQC2.Label {
            text: appInfo.application.contentRatingDescription
            textFormat: Text.MarkdownText
            wrapMode: Text.Wrap
        }
    }

    Kirigami.Dialog {
        id: licenseDetailsDialog

        function openWithLicenseType(licenseType: string): void {
            licenseExplanation.text = appInfo.explanationForLicenseType(licenseType);
            open();
        }

        parent: appInfo.QQC2.Overlay.overlay
        width: Kirigami.Units.gridUnit * 25
        standardButtons: Kirigami.Dialog.NoButton

        title: i18nc("@title:window", "License Information")

        TextEdit {
            id: licenseExplanation

            leftPadding: Kirigami.Units.largeSpacing
            rightPadding: Kirigami.Units.largeSpacing
            bottomPadding: Kirigami.Units.largeSpacing

            wrapMode: Text.Wrap
            textFormat: TextEdit.RichText
            readOnly: true

            color: Kirigami.Theme.textColor
            selectedTextColor: Kirigami.Theme.highlightedTextColor
            selectionColor: Kirigami.Theme.highlightColor

            onLinkActivated: url => Qt.openUrlExternally(url)

            HoverHandler {
                acceptedButtons: Qt.NoButton
                cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
            }
        }
    }
}