فهرست منبع

Merge branch 'main' into empty_state

ashilkn 2 سال پیش
والد
کامیت
7062c2acbd

+ 3 - 0
ios/Runner/Info.plist

@@ -66,6 +66,9 @@
 		<false/>
 		<key>NSFaceIDUsageDescription</key>
 		<string>Please allow ente to lock itself with FaceID or TouchID</string>
+		<key>NSCameraUsageDescription</key>
+		<string>Please allow access to your camera so that you can take photos
+		within ente</string>
 		<key>NSPhotoLibraryUsageDescription</key>
 		<string>Please allow access to your photos so that ente can encrypt and back them up.</string>
 		<key>UIBackgroundModes</key>

+ 12 - 0
lib/extensions/input_formatter.dart

@@ -0,0 +1,12 @@
+import "package:flutter/services.dart";
+
+class UpperCaseTextFormatter extends TextInputFormatter {
+  @override
+  TextEditingValue formatEditUpdate(
+      TextEditingValue oldValue, TextEditingValue newValue) {
+    return TextEditingValue(
+      text: newValue.text.toUpperCase(),
+      selection: newValue.selection,
+    );
+  }
+}

+ 22 - 0
lib/gateways/storage_bonus_gw.dart

@@ -0,0 +1,22 @@
+import "package:dio/dio.dart";
+import "package:photos/models/api/storage_bonus/storage_bonus.dart";
+
+class StorageBonusGateway {
+  final Dio _enteDio;
+
+  StorageBonusGateway(this._enteDio);
+
+  Future<ReferralView> getReferralView() async {
+    final response = await _enteDio.get("/storage-bonus/referral-view");
+    return ReferralView.fromJson(response.data);
+  }
+
+  Future<void> claimReferralCode(String code) {
+    return _enteDio.post("/storage-bonus/referral-claim?code=$code");
+  }
+
+  Future<BonusDetails> getBonusDetails() async {
+    final response = await _enteDio.get("/storage-bonus/details");
+    return BonusDetails.fromJson(response.data);
+  }
+}

+ 5 - 1
lib/main.dart

@@ -28,6 +28,7 @@ import "package:photos/services/object_detection/object_detection_service.dart";
 import 'package:photos/services/push_service.dart';
 import 'package:photos/services/remote_sync_service.dart';
 import 'package:photos/services/search_service.dart';
+import "package:photos/services/storage_bonus_service.dart";
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/services/trash_sync_service.dart';
 import 'package:photos/services/update_service.dart';
@@ -153,6 +154,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
   LocalSettings.instance.init(preferences);
   LocalFileUpdateService.instance.init(preferences);
   SearchService.instance.init();
+  StorageBonusService.instance.init(preferences);
   if (Platform.isIOS) {
     PushService.instance.init().then((_) {
       FirebaseMessaging.onBackgroundMessage(
@@ -161,7 +163,9 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
     });
   }
   FeatureFlagService.instance.init();
-  await ObjectDetectionService.instance.init();
+  if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
+    await ObjectDetectionService.instance.init();
+  }
   _logger.info("Initialization done");
 }
 

+ 150 - 0
lib/models/api/storage_bonus/storage_bonus.dart

@@ -0,0 +1,150 @@
+class ReferralView {
+  PlanInfo planInfo;
+  String code;
+  bool enableApplyCode;
+  bool isFamilyMember;
+  bool hasAppliedCode;
+  int claimedStorage;
+
+  ReferralView({
+    required this.planInfo,
+    required this.code,
+    required this.enableApplyCode,
+    required this.isFamilyMember,
+    required this.hasAppliedCode,
+    required this.claimedStorage,
+  });
+
+  factory ReferralView.fromJson(Map<String, dynamic> json) => ReferralView(
+        planInfo: PlanInfo.fromJson(json["planInfo"]),
+        code: json["code"],
+        enableApplyCode: json["enableApplyCode"],
+        isFamilyMember: json["isFamilyMember"],
+        hasAppliedCode: json["hasAppliedCode"],
+        claimedStorage: json["claimedStorage"],
+      );
+
+  Map<String, dynamic> toJson() => {
+        "planInfo": planInfo.toJson(),
+        "code": code,
+        "enableApplyCode": enableApplyCode,
+        "isFamilyMember": isFamilyMember,
+        "hasAppliedCode": hasAppliedCode,
+        "claimedStorage": claimedStorage,
+      };
+}
+
+class PlanInfo {
+  bool isEnabled;
+  String planType;
+  int storageInGB;
+  int maxClaimableStorageInGB;
+
+  PlanInfo({
+    required this.isEnabled,
+    required this.planType,
+    required this.storageInGB,
+    required this.maxClaimableStorageInGB,
+  });
+
+  factory PlanInfo.fromJson(Map<String, dynamic> json) => PlanInfo(
+        isEnabled: json["isEnabled"],
+        planType: json["planType"],
+        storageInGB: json["storageInGB"],
+        maxClaimableStorageInGB: json["maxClaimableStorageInGB"],
+      );
+
+  Map<String, dynamic> toJson() => {
+        "isEnabled": isEnabled,
+        "planType": planType,
+        "storageInGB": storageInGB,
+        "maxClaimableStorageInGB": maxClaimableStorageInGB,
+      };
+}
+
+class ReferralStat {
+  String planType;
+  int totalCount;
+  int upgradedCount;
+
+  ReferralStat(this.planType, this.totalCount, this.upgradedCount);
+
+  factory ReferralStat.fromJson(Map<String, dynamic> json) {
+    return ReferralStat(
+      json['planType'],
+      json['totalCount'],
+      json['upgradedCount'],
+    );
+  }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'planType': planType,
+      'totalCount': totalCount,
+      'upgradedCount': upgradedCount,
+    };
+  }
+}
+
+class Bonus {
+  int storage;
+  String type;
+  int validTill;
+  bool isRevoked;
+
+  Bonus(this.storage, this.type, this.validTill, this.isRevoked);
+
+  // fromJson
+  factory Bonus.fromJson(Map<String, dynamic> json) {
+    return Bonus(
+      json['storage'],
+      json['type'],
+      json['validTill'],
+      json['isRevoked'],
+    );
+  }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'storage': storage,
+      'type': type,
+      'validTill': validTill,
+      'isRevoked': isRevoked,
+    };
+  }
+}
+
+class BonusDetails {
+  List<ReferralStat> referralStats;
+  List<Bonus> bonuses;
+  int refCount;
+  int refUpgradeCount;
+  bool hasAppliedCode;
+
+  BonusDetails({
+    required this.referralStats,
+    required this.bonuses,
+    required this.refCount,
+    required this.refUpgradeCount,
+    required this.hasAppliedCode,
+  });
+
+  factory BonusDetails.fromJson(Map<String, dynamic> json) => BonusDetails(
+        referralStats: List<ReferralStat>.from(
+            json["referralStats"].map((x) => ReferralStat.fromJson(x))),
+        bonuses:
+            List<Bonus>.from(json["bonuses"].map((x) => Bonus.fromJson(x))),
+        refCount: json["refCount"],
+        refUpgradeCount: json["refUpgradeCount"],
+        hasAppliedCode: json["hasAppliedCode"],
+      );
+
+  Map<String, dynamic> toJson() => {
+        "referralStats":
+            List<dynamic>.from(referralStats.map((x) => x.toJson())),
+        "bonuses": List<dynamic>.from(bonuses.map((x) => x.toJson())),
+        "refCount": refCount,
+        "refUpgradeCount": refUpgradeCount,
+        "hasAppliedCode": hasAppliedCode,
+      };
+}

+ 8 - 3
lib/services/billing_service.dart

@@ -168,12 +168,17 @@ class BillingService {
     if (userDetails.subscription.productID == freeProductID) {
       await showErrorDialog(
         context,
-        "Share your storage plan with your family members!",
-        "Customers on paid plans can add up to 5 family members without paying extra. Each member gets their own private space.",
+        "Family plans",
+        "Add 5 family members to your existing plan without paying extra.\n"
+            "\nEach member gets their own private space, and cannot see each "
+            "other's files unless they're shared.\n\nFamily plans are "
+            "available to customers who have a paid ente subscription.\n\n"
+            "Subscribe now to get started!",
       );
       return;
     }
-    final dialog = createProgressDialog(context, "Please wait...");
+    final dialog =
+        createProgressDialog(context, "Please wait...", isDismissible: true);
     await dialog.show();
     try {
       final String jwtToken = await UserService.instance.getFamiliesToken();

+ 7 - 1
lib/services/collections_service.dart

@@ -1,7 +1,6 @@
 import 'dart:async';
 import 'dart:convert';
 import 'dart:math';
-import 'dart:typed_data';
 
 import 'package:collection/collection.dart';
 import 'package:dio/dio.dart';
@@ -183,6 +182,13 @@ class CollectionsService {
         .toSet();
   }
 
+  Future<List<CollectionWithThumbnail>> getArchivedCollectionWithThumb() async {
+    final allCollections = await getCollectionsWithThumbnails();
+    return allCollections
+        .where((element) => element.collection.isArchived())
+        .toList();
+  }
+
   Set<int> getHiddenCollections() {
     return _collectionIDToCollections.values
         .toList()

+ 33 - 0
lib/services/storage_bonus_service.dart

@@ -0,0 +1,33 @@
+import "package:photos/core/network/network.dart";
+import "package:photos/gateways/storage_bonus_gw.dart";
+import "package:shared_preferences/shared_preferences.dart";
+
+class StorageBonusService {
+  late StorageBonusGateway gateway;
+  late SharedPreferences prefs;
+
+  final String _showStorageBonus = "showStorageBonus.showBanner";
+
+  void init(SharedPreferences preferences) {
+    prefs = preferences;
+    gateway = StorageBonusGateway(NetworkClient.instance.enteDio);
+  }
+
+  StorageBonusService._privateConstructor();
+
+  static StorageBonusService instance =
+      StorageBonusService._privateConstructor();
+
+  bool shouldShowStorageBonus() {
+    return prefs.getBool(_showStorageBonus) ?? true;
+  }
+
+  void markStorageBonusAsDone() {
+    prefs.setBool(_showStorageBonus, false).ignore();
+  }
+
+  // getter for gateway
+  StorageBonusGateway getGateway() {
+    return gateway;
+  }
+}

+ 1 - 1
lib/services/update_service.dart

@@ -16,7 +16,7 @@ class UpdateService {
   static final UpdateService instance = UpdateService._privateConstructor();
   static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
   static const changeLogVersionKey = "update_change_log_key";
-  static const currentChangeLogVersion = 4;
+  static const currentChangeLogVersion = 6;
 
   LatestVersionInfo? _latestVersion;
   final _logger = Logger("UpdateService");

+ 6 - 3
lib/ui/collections_gallery_widget.dart

@@ -85,9 +85,12 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
     final List<CollectionWithThumbnail> collectionsWithThumbnail =
         await CollectionsService.instance.getCollectionsWithThumbnails();
 
-    // Remove uncategorized collection
-    collectionsWithThumbnail
-        .removeWhere((t) => t.collection.type == CollectionType.uncategorized);
+    // Remove uncategorized collection and archived collections
+    collectionsWithThumbnail.removeWhere(
+      (t) =>
+          t.collection.type == CollectionType.uncategorized ||
+          t.collection.isArchived(),
+    );
     final ListMatch<CollectionWithThumbnail> favMathResult =
         collectionsWithThumbnail.splitMatch(
       (element) => element.collection.type == CollectionType.favorites,

+ 0 - 71
lib/ui/components/notification_warning_widget.dart

@@ -1,71 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:photos/ente_theme_data.dart';
-import 'package:photos/theme/colors.dart';
-import 'package:photos/theme/text_style.dart';
-import 'package:photos/ui/components/icon_button_widget.dart';
-
-class NotificationWarningWidget extends StatelessWidget {
-  final IconData warningIcon;
-  final IconData actionIcon;
-  final String text;
-  final GestureTapCallback onTap;
-
-  const NotificationWarningWidget({
-    Key? key,
-    required this.warningIcon,
-    required this.actionIcon,
-    required this.text,
-    required this.onTap,
-  }) : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    return Center(
-      child: GestureDetector(
-        onTap: onTap,
-        child: Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
-          child: Container(
-            decoration: BoxDecoration(
-              borderRadius: const BorderRadius.all(
-                Radius.circular(8),
-              ),
-              boxShadow: Theme.of(context).colorScheme.enteTheme.shadowMenu,
-              color: warning500,
-            ),
-            child: Padding(
-              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-              child: Row(
-                mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                children: [
-                  Icon(
-                    warningIcon,
-                    size: 36,
-                    color: Colors.white,
-                  ),
-                  const SizedBox(width: 12),
-                  Flexible(
-                    child: Text(
-                      text,
-                      style: darkTextTheme.bodyBold,
-                      textAlign: TextAlign.left,
-                    ),
-                  ),
-                  const SizedBox(width: 12),
-                  IconButtonWidget(
-                    icon: actionIcon,
-                    iconButtonType: IconButtonType.rounded,
-                    iconColor: strokeBaseDark,
-                    defaultColor: fillFaintDark,
-                    pressedColor: fillMutedDark,
-                    onTap: onTap,
-                  )
-                ],
-              ),
-            ),
-          ),
-        ),
-      ),
-    );
-  }
-}

+ 97 - 0
lib/ui/components/notification_widget.dart

@@ -0,0 +1,97 @@
+import 'package:flutter/material.dart';
+import 'package:photos/ente_theme_data.dart';
+import 'package:photos/theme/colors.dart';
+import 'package:photos/theme/text_style.dart';
+import 'package:photos/ui/components/icon_button_widget.dart';
+
+// CreateNotificationType enum
+enum NotificationType {
+  warning,
+  banner,
+}
+
+class NotificationWidget extends StatelessWidget {
+  final IconData startIcon;
+  final IconData actionIcon;
+  final String text;
+  final String? subText;
+  final GestureTapCallback onTap;
+  final NotificationType type;
+
+  const NotificationWidget({
+    Key? key,
+    required this.startIcon,
+    required this.actionIcon,
+    required this.text,
+    required this.onTap,
+    this.subText,
+    this.type = NotificationType.warning,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    Color backgroundColor = Colors.white;
+    switch (type) {
+      case NotificationType.warning:
+        backgroundColor = warning500;
+        break;
+      case NotificationType.banner:
+        backgroundColor = backgroundElevated2Dark;
+        break;
+    }
+    return Center(
+      child: GestureDetector(
+        onTap: onTap,
+        child: Container(
+          decoration: BoxDecoration(
+            borderRadius: const BorderRadius.all(
+              Radius.circular(8),
+            ),
+            boxShadow: Theme.of(context).colorScheme.enteTheme.shadowMenu,
+            color: backgroundColor,
+          ),
+          child: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Icon(
+                  startIcon,
+                  size: 36,
+                  color: Colors.white,
+                ),
+                const SizedBox(width: 12),
+                Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      text,
+                      style: darkTextTheme.bodyBold,
+                      textAlign: TextAlign.left,
+                    ),
+                    subText != null
+                        ? Text(
+                            subText!,
+                            style: darkTextTheme.mini
+                                .copyWith(color: textMutedDark),
+                          )
+                        : const SizedBox.shrink(),
+                  ],
+                ),
+                const SizedBox(width: 12),
+                IconButtonWidget(
+                  icon: actionIcon,
+                  iconButtonType: IconButtonType.rounded,
+                  iconColor: strokeBaseDark,
+                  defaultColor: fillFaintDark,
+                  pressedColor: fillMutedDark,
+                  onTap: onTap,
+                )
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 170 - 0
lib/ui/growth/apply_code_screen.dart

@@ -0,0 +1,170 @@
+import "package:flutter/material.dart";
+import "package:logging/logging.dart";
+import "package:photos/extensions/input_formatter.dart";
+import "package:photos/services/storage_bonus_service.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/button_widget.dart";
+import "package:photos/ui/components/icon_button_widget.dart";
+import "package:photos/ui/components/models/button_type.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import "package:photos/ui/components/title_bar_widget.dart";
+import "package:photos/utils/dialog_util.dart";
+
+class ApplyCodeScreen extends StatefulWidget {
+  const ApplyCodeScreen({super.key});
+
+  @override
+  State<ApplyCodeScreen> createState() => _ApplyCodeScreenState();
+}
+
+class _ApplyCodeScreenState extends State<ApplyCodeScreen> {
+  late TextEditingController _textController;
+
+  late FocusNode textFieldFocusNode;
+  String code = "";
+
+  @override
+  void initState() {
+    _textController = TextEditingController();
+    textFieldFocusNode = FocusNode();
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _textController.dispose();
+    textFieldFocusNode.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colorScheme = getEnteColorScheme(context);
+    final textStyle = getEnteTextTheme(context);
+    textFieldFocusNode.requestFocus();
+    return Scaffold(
+      body: CustomScrollView(
+        primary: false,
+        slivers: <Widget>[
+          TitleBarWidget(
+            flexibleSpaceTitle: const TitleBarTitleWidget(
+              title: "Apply code",
+            ),
+            actionIcons: [
+              IconButtonWidget(
+                icon: Icons.close_outlined,
+                iconButtonType: IconButtonType.secondary,
+                onTap: () {
+                  // Go three screen back, similar to pop thrice
+                  Navigator.of(context)
+                    ..pop()
+                    ..pop()
+                    ..pop();
+                },
+              ),
+            ],
+          ),
+          SliverList(
+            delegate: SliverChildBuilderDelegate(
+              (delegateBuildContext, index) {
+                return Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 16),
+                  child: Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 20),
+                    child: Column(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Column(
+                          children: [
+                            Text(
+                              "Enter the code provided by your friend to "
+                              "claim free storage for both of you",
+                              style: textStyle.small
+                                  .copyWith(color: colorScheme.textMuted),
+                            ),
+                            const SizedBox(height: 24),
+                            _getInputField(),
+                            // Container with 8 border radius and red color
+                          ],
+                        ),
+                      ],
+                    ),
+                  ),
+                );
+              },
+              childCount: 1,
+            ),
+          ),
+          SliverFillRemaining(
+            child: SafeArea(
+              child: Padding(
+                padding: const EdgeInsets.all(12.0),
+                child: Column(
+                  mainAxisAlignment: MainAxisAlignment.end,
+                  children: [
+                    ButtonWidget(
+                      buttonType: ButtonType.neutral,
+                      buttonSize: ButtonSize.large,
+                      labelText: "Apply",
+                      isDisabled: code.trim().length < 4,
+                      onTap: () async {
+                        try {
+                          await StorageBonusService.instance
+                              .getGateway()
+                              .claimReferralCode(code.trim().toUpperCase());
+                          Navigator.of(context).pop();
+                        } catch (e) {
+                          Logger('$runtimeType')
+                              .severe("failed to apply referral", e);
+                          showErrorDialogForException(
+                            context: context,
+                            exception: e as Exception,
+                          );
+                        }
+                      },
+                    )
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _getInputField() {
+    return TextFormField(
+      controller: _textController,
+      focusNode: textFieldFocusNode,
+      style: getEnteTextTheme(context).body,
+      inputFormatters: [UpperCaseTextFormatter()],
+      textCapitalization: TextCapitalization.sentences,
+      decoration: InputDecoration(
+        focusedBorder: OutlineInputBorder(
+          borderRadius: const BorderRadius.all(Radius.circular(4.0)),
+          borderSide:
+              BorderSide(color: getEnteColorScheme(context).strokeMuted),
+        ),
+        fillColor: getEnteColorScheme(context).fillFaint,
+        filled: true,
+        hintText: 'Enter referral code',
+        contentPadding: const EdgeInsets.symmetric(
+          horizontal: 16,
+          vertical: 14,
+        ),
+        border: UnderlineInputBorder(
+          borderSide: BorderSide.none,
+          borderRadius: BorderRadius.circular(8),
+        ),
+      ),
+      onChanged: (value) {
+        code = value.trim();
+        setState(() {});
+      },
+      autocorrect: false,
+      keyboardType: TextInputType.emailAddress,
+      textInputAction: TextInputAction.next,
+    );
+  }
+}

+ 316 - 0
lib/ui/growth/referral_screen.dart

@@ -0,0 +1,316 @@
+import "package:dotted_border/dotted_border.dart";
+import "package:flutter/material.dart";
+import "package:photos/models/api/storage_bonus/storage_bonus.dart";
+import "package:photos/models/user_details.dart";
+import "package:photos/services/storage_bonus_service.dart";
+import "package:photos/services/user_service.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/ui/common/web_page.dart";
+import "package:photos/ui/components/captioned_text_widget.dart";
+import "package:photos/ui/components/divider_widget.dart";
+import "package:photos/ui/components/icon_button_widget.dart";
+import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import "package:photos/ui/components/title_bar_widget.dart";
+import "package:photos/ui/growth/apply_code_screen.dart";
+import "package:photos/ui/growth/storage_details_screen.dart";
+import "package:photos/utils/data_util.dart";
+import "package:photos/utils/navigation_util.dart";
+import "package:photos/utils/share_util.dart";
+
+class ReferralScreen extends StatefulWidget {
+  const ReferralScreen({super.key});
+
+  @override
+  State<ReferralScreen> createState() => _ReferralScreenState();
+}
+
+class _ReferralScreenState extends State<ReferralScreen> {
+  bool canApplyCode = true;
+  void _safeUIUpdate() {
+    if (mounted) {
+      setState(() => {});
+    }
+  }
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: CustomScrollView(
+        primary: false,
+        slivers: <Widget>[
+          TitleBarWidget(
+            flexibleSpaceTitle: const TitleBarTitleWidget(
+              title: "Claim free storage",
+            ),
+            flexibleSpaceCaption: "Invite your friends",
+            actionIcons: [
+              IconButtonWidget(
+                icon: Icons.close_outlined,
+                iconButtonType: IconButtonType.secondary,
+                onTap: () {
+                  Navigator.pop(context);
+                  Navigator.pop(context);
+                },
+              ),
+            ],
+          ),
+          SliverList(
+            delegate: SliverChildBuilderDelegate(
+              (delegateBuildContext, index) {
+                return Padding(
+                  padding:
+                      const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
+                  child: FutureBuilder<ReferralView>(
+                    future: StorageBonusService.instance
+                        .getGateway()
+                        .getReferralView(),
+                    builder: (context, snapshot) {
+                      if (snapshot.hasData) {
+                        return ReferralWidget(
+                          snapshot.data!,
+                          UserService.instance.getCachedUserDetails()!,
+                          _safeUIUpdate,
+                        );
+                      } else if (snapshot.hasError) {
+                        return const Center(
+                          child: Padding(
+                            padding: EdgeInsets.all(24.0),
+                            child: Text(
+                              "Unable to fetch referral details. Please try again later.",
+                            ),
+                          ),
+                        );
+                      }
+                      {
+                        return const EnteLoadingWidget();
+                      }
+                    },
+                  ),
+                );
+              },
+              childCount: 1,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class ReferralWidget extends StatelessWidget {
+  final ReferralView referralView;
+  final UserDetails userDetails;
+  final Function notifyParent;
+
+  const ReferralWidget(
+    this.referralView,
+    this.userDetails,
+    this.notifyParent, {
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final colorScheme = getEnteColorScheme(context);
+    final textStyle = getEnteTextTheme(context);
+    final bool isReferralEnabled = referralView.planInfo.isEnabled;
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        // Container with 8 border radius and red color
+        isReferralEnabled
+            ? InkWell(
+                onTap: () {
+                  shareText(
+                      "ente referral code: ${referralView.code} \n\nApply it in Settings → General → Referrals to get 10 GB free after you signup for a paid plan\n\nhttps://ente.io");
+                },
+                child: Container(
+                  width: double.infinity,
+                  decoration: BoxDecoration(
+                    border: Border.all(
+                      color: colorScheme.strokeFaint,
+                      width: 1,
+                    ),
+                    borderRadius: BorderRadius.circular(8),
+                  ),
+                  child: Padding(
+                    padding: const EdgeInsets.symmetric(
+                      vertical: 12,
+                      horizontal: 12,
+                    ),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        const Text(
+                          "1. Give this code to your "
+                          "friends",
+                        ),
+                        const SizedBox(height: 12),
+                        Center(
+                          child: DottedBorder(
+                            color: colorScheme.strokeMuted,
+                            //color of dotted/dash line
+                            strokeWidth: 1,
+                            //thickness of dash/dots
+                            dashPattern: const [6, 6],
+                            radius: const Radius.circular(8),
+                            child: Padding(
+                              padding: const EdgeInsets.only(
+                                left: 26.0,
+                                top: 14,
+                                right: 12,
+                                bottom: 14,
+                              ),
+                              child: Row(
+                                mainAxisSize: MainAxisSize.min,
+                                children: [
+                                  Text(
+                                    referralView.code,
+                                    style: textStyle.bodyBold.copyWith(
+                                      color: colorScheme.primary700,
+                                    ),
+                                  ),
+                                  const SizedBox(width: 12),
+                                  Icon(
+                                    Icons.adaptive.share,
+                                    size: 22,
+                                    color: colorScheme.strokeMuted,
+                                  )
+                                ],
+                              ),
+                            ),
+                          ),
+                        ),
+                        const SizedBox(height: 12),
+                        const Text(
+                          "2. They sign up for a paid plan",
+                        ),
+                        const SizedBox(height: 12),
+                        Text(
+                          "3. Both of you get ${referralView.planInfo.storageInGB} "
+                          "GB* free",
+                        ),
+                      ],
+                    ),
+                  ),
+                ),
+              )
+            : Padding(
+                padding: const EdgeInsets.symmetric(vertical: 48),
+                child: Center(
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.center,
+                    children: [
+                      Icon(
+                        Icons.error_outline,
+                        color: colorScheme.strokeMuted,
+                      ),
+                      const SizedBox(height: 12),
+                      Text("Referrals are currently paused",
+                          style: textStyle.small
+                              .copyWith(color: colorScheme.textFaint)),
+                    ],
+                  ),
+                ),
+              ),
+        const SizedBox(height: 4),
+        isReferralEnabled
+            ? Text(
+                "* You can at max double your storage",
+                style: textStyle.mini.copyWith(
+                  color: colorScheme.textMuted,
+                ),
+                textAlign: TextAlign.left,
+              )
+            : const SizedBox.shrink(),
+        const SizedBox(height: 24),
+        referralView.enableApplyCode
+            ? MenuItemWidget(
+                captionedTextWidget: const CaptionedTextWidget(
+                  title: "Apply code",
+                ),
+                menuItemColor: colorScheme.fillFaint,
+                trailingWidget: Icon(
+                  Icons.chevron_right_outlined,
+                  color: colorScheme.strokeBase,
+                ),
+                singleBorderRadius: 8,
+                alignCaptionedTextToLeft: true,
+                isBottomBorderRadiusRemoved: true,
+                onTap: () async {
+                  await routeToPage(
+                    context,
+                    const ApplyCodeScreen(),
+                  );
+                  notifyParent();
+                },
+              )
+            : const SizedBox.shrink(),
+        referralView.enableApplyCode
+            ? DividerWidget(
+                dividerType: DividerType.menu,
+                bgColor: colorScheme.fillFaint,
+              )
+            : const SizedBox.shrink(),
+        MenuItemWidget(
+          captionedTextWidget: const CaptionedTextWidget(
+            title: "FAQ",
+          ),
+          menuItemColor: colorScheme.fillFaint,
+          trailingWidget: Icon(
+            Icons.chevron_right_outlined,
+            color: colorScheme.strokeBase,
+          ),
+          singleBorderRadius: 8,
+          isTopBorderRadiusRemoved: referralView.enableApplyCode,
+          alignCaptionedTextToLeft: true,
+          onTap: () async {
+            routeToPage(
+                context,
+                const WebPage(
+                    "FAQ", "https://ente.io/faq/general/referral-program"));
+          },
+        ),
+        const SizedBox(height: 24),
+        Padding(
+          padding: const EdgeInsets.symmetric(
+            horizontal: 8.0,
+            vertical: 6.0,
+          ),
+          child: Text(
+            "${referralView.isFamilyMember ? 'Your family has' : 'You have'} claimed "
+            "${convertBytesToAbsoluteGBs(referralView.claimedStorage)} GB so far",
+            style: textStyle.small.copyWith(
+              color: colorScheme.textMuted,
+            ),
+          ),
+        ),
+        MenuItemWidget(
+          captionedTextWidget: const CaptionedTextWidget(
+            title: "Details",
+          ),
+          menuItemColor: colorScheme.fillFaint,
+          trailingWidget: Icon(
+            Icons.chevron_right_outlined,
+            color: colorScheme.strokeBase,
+          ),
+          singleBorderRadius: 8,
+          alignCaptionedTextToLeft: true,
+          onTap: () async {
+            routeToPage(
+              context,
+              StorageDetailsScreen(referralView, userDetails),
+            );
+          },
+        ),
+      ],
+    );
+  }
+}

+ 217 - 0
lib/ui/growth/storage_details_screen.dart

@@ -0,0 +1,217 @@
+import "dart:math";
+
+import "package:flutter/material.dart";
+import "package:photos/models/api/storage_bonus/storage_bonus.dart";
+import "package:photos/models/user_details.dart";
+import "package:photos/services/storage_bonus_service.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/ui/components/icon_button_widget.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import "package:photos/ui/components/title_bar_widget.dart";
+import "package:photos/utils/data_util.dart";
+
+class StorageDetailsScreen extends StatefulWidget {
+  final ReferralView referralView;
+  final UserDetails userDetails;
+  const StorageDetailsScreen(this.referralView, this.userDetails, {super.key});
+
+  @override
+  State<StorageDetailsScreen> createState() => _StorageDetailsScreenState();
+}
+
+class _StorageDetailsScreenState extends State<StorageDetailsScreen> {
+  bool canApplyCode = true;
+
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colorScheme = getEnteColorScheme(context);
+    final textStyle = getEnteTextTheme(context);
+    return Scaffold(
+      body: CustomScrollView(
+        primary: false,
+        slivers: <Widget>[
+          TitleBarWidget(
+            flexibleSpaceTitle: const TitleBarTitleWidget(
+              title: "Claim free storage",
+            ),
+            flexibleSpaceCaption: "Details",
+            actionIcons: [
+              IconButtonWidget(
+                icon: Icons.close_outlined,
+                iconButtonType: IconButtonType.secondary,
+                onTap: () {
+                  Navigator.of(context)
+                    ..pop()
+                    ..pop()
+                    ..pop();
+                },
+              ),
+            ],
+          ),
+          SliverList(
+            delegate: SliverChildBuilderDelegate(
+              (delegateBuildContext, index) {
+                return Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 12),
+                  child: Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 20),
+                    // wrap the child inside a FutureBuilder to get the
+                    // current state of the TextField
+                    child: FutureBuilder<BonusDetails>(
+                      future: StorageBonusService.instance
+                          .getGateway()
+                          .getBonusDetails(),
+                      builder: (context, snapshot) {
+                        if (snapshot.connectionState ==
+                            ConnectionState.waiting) {
+                          return const Center(
+                            child: Padding(
+                              padding: EdgeInsets.only(top: 48.0),
+                              child: EnteLoadingWidget(),
+                            ),
+                          );
+                        }
+                        if (snapshot.hasError) {
+                          debugPrint(snapshot.error.toString());
+                          return const Text("Oops, something went wrong");
+                        } else {
+                          final BonusDetails data = snapshot.data!;
+                          return Padding(
+                            padding:
+                                const EdgeInsets.symmetric(horizontal: 12.0),
+                            child: Column(
+                              crossAxisAlignment: CrossAxisAlignment.start,
+                              children: [
+                                BonusInfoSection(
+                                  sectionName: "People using your code",
+                                  leftValue: data.refUpgradeCount,
+                                  leftUnitName: "eligible",
+                                  rightValue: data.refUpgradeCount >= 0
+                                      ? data.refCount
+                                      : null,
+                                  rightUnitName: "total",
+                                  showUnit: data.refCount > 0,
+                                ),
+                                data.hasAppliedCode
+                                    ? const BonusInfoSection(
+                                        sectionName: "Code used by you",
+                                        leftValue: 1,
+                                        showUnit: false,
+                                      )
+                                    : const SizedBox.shrink(),
+                                BonusInfoSection(
+                                  sectionName: "Free storage claimed",
+                                  leftValue: convertBytesToAbsoluteGBs(
+                                      widget.referralView.claimedStorage),
+                                  leftUnitName: "GB",
+                                  rightValue: null,
+                                ),
+                                BonusInfoSection(
+                                  sectionName: "Free storage usable",
+                                  leftValue: convertBytesToAbsoluteGBs(min(
+                                    widget.referralView.claimedStorage,
+                                    widget.userDetails.getTotalStorage(),
+                                  )),
+                                  leftUnitName: "GB",
+                                  rightValue: convertBytesToAbsoluteGBs(
+                                      widget.userDetails.getTotalStorage()),
+                                  rightUnitName: "GB",
+                                ),
+                                const SizedBox(
+                                  height: 24,
+                                ),
+                                Text(
+                                  "Usable storage is limited by your current"
+                                  " plan. Excess"
+                                  " claimed storage will automatically become"
+                                  " usable when you upgrade your plan.",
+                                  style: textStyle.small
+                                      .copyWith(color: colorScheme.textMuted),
+                                )
+                              ],
+                            ),
+                          );
+                        }
+                      },
+                    ),
+                  ),
+                );
+              },
+              childCount: 1,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class BonusInfoSection extends StatelessWidget {
+  final String sectionName;
+  final bool showUnit;
+  final String leftUnitName;
+  final String rightUnitName;
+  final int leftValue;
+  final int? rightValue;
+
+  const BonusInfoSection({
+    super.key,
+    required this.sectionName,
+    required this.leftValue,
+    this.leftUnitName = "GB",
+    this.rightValue,
+    this.rightUnitName = "GB",
+    this.showUnit = true,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final colorScheme = getEnteColorScheme(context);
+    final textStyle = getEnteTextTheme(context);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Text(
+          sectionName,
+          style: textStyle.body.copyWith(
+            color: colorScheme.textMuted,
+          ),
+        ),
+        const SizedBox(height: 2),
+        RichText(
+          text: TextSpan(
+            children: [
+              TextSpan(
+                text: leftValue.toString(),
+                style: textStyle.h3,
+              ),
+              TextSpan(
+                text: showUnit ? " $leftUnitName" : "",
+                style: textStyle.large,
+              ),
+              TextSpan(
+                text: (rightValue != null && rightValue! > 0)
+                    ? " / ${rightValue.toString()}"
+                    : "",
+                style: textStyle.h3,
+              ),
+              TextSpan(
+                text: showUnit && (rightValue != null && rightValue! > 0)
+                    ? " $rightUnitName"
+                    : "",
+                style: textStyle.large,
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(height: 24),
+      ],
+    );
+  }
+}

+ 16 - 12
lib/ui/home/status_bar_widget.dart

@@ -10,7 +10,7 @@ import 'package:photos/services/user_remote_flag_service.dart';
 import 'package:photos/theme/text_style.dart';
 import 'package:photos/ui/account/verify_recovery_page.dart';
 import 'package:photos/ui/components/home_header_widget.dart';
-import 'package:photos/ui/components/notification_warning_widget.dart';
+import 'package:photos/ui/components/notification_widget.dart';
 import 'package:photos/ui/home/header_error_widget.dart';
 import 'package:photos/utils/navigation_util.dart';
 
@@ -97,17 +97,21 @@ class _StatusBarWidgetState extends State<StatusBarWidget> {
             ? HeaderErrorWidget(error: _syncError)
             : const SizedBox.shrink(),
         UserRemoteFlagService.instance.shouldShowRecoveryVerification()
-            ? NotificationWarningWidget(
-                warningIcon: Icons.error_outline,
-                actionIcon: Icons.arrow_forward,
-                text: "Confirm your recovery key",
-                onTap: () async => {
-                  await routeToPage(
-                    context,
-                    const VerifyRecoveryPage(),
-                    forceCustomPageRoute: true,
-                  )
-                },
+            ? Padding(
+                padding:
+                    const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
+                child: NotificationWidget(
+                  startIcon: Icons.error_outline,
+                  actionIcon: Icons.arrow_forward,
+                  text: "Confirm your recovery key",
+                  onTap: () async => {
+                    await routeToPage(
+                      context,
+                      const VerifyRecoveryPage(),
+                      forceCustomPageRoute: true,
+                    )
+                  },
+                ),
               )
             : const SizedBox.shrink()
       ],

+ 18 - 29
lib/ui/notification/update/change_log_page.dart

@@ -1,3 +1,5 @@
+import "dart:io";
+
 import 'package:flutter/material.dart';
 import 'package:photos/services/update_service.dart';
 import 'package:photos/theme/ente_theme.dart';
@@ -103,37 +105,24 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
     final List<ChangeLogEntry> items = [];
     items.add(
       ChangeLogEntry(
-        "Collaborative albums ✨",
-        "Much awaited, they're here now - create albums where multiple ente "
-            "users can add photos!\n\nWhen sharing an album, you can specify if"
-            " you want to add someone as a viewer or a collaborator. Collaborators can add photos "
-            "to the shared album.\n\nAlbums can have both collaborators and viewers, and as many as "
-            "you like. Storage is only counted once, for the person who uploaded the photo."
-            "\n\nHead over to the sharing options for an album to start adding collaborators.",
-      ),
-    );
-    items.add(
-      ChangeLogEntry(
-        "Uncategorized",
-        "You can now keep photos that do not belong to a specific album."
-            "\n\nThis will simplify deletion and make it safer since now ente "
-            "will have a place to put photos that don't belong to any album "
-            "instead of always deleting them.\n\nThis will also allow you to "
-            "choose between keeping vs deleting photos present in the album, "
-            "when deleting an album.\n\nUncategorized photos can be seen from "
-            "the bottom of the albums tab.",
-      ),
-    );
-
-    items.add(
-      ChangeLogEntry(
-        '''Cleaner album picker''',
-        "Among other improvements, the list of albums that is shown when adding "
-            "or moving photos gets a facelift, and an issue causing the photo "
-            "zoom to be reset after loading the full resolution photo has been fixed.",
-        isFeature: false,
+        "Referrals ✨",
+        "You can now double your storage by referring your friends and family"
+            ". Both you and your loved ones will get 10 GB of storage when "
+            "they upgrade to a paid plan.\n\nGo to Settings -> General -> "
+            "Referral to get started!",
       ),
     );
+    if (Platform.isAndroid) {
+      items.add(
+        ChangeLogEntry(
+          "Pick Files",
+          "While sharing photos and videos through other apps, ente will now "
+              "be an option to pick files from. This means you can now easily"
+              " attach files backed up to ente.\n\nConsider this the first "
+              "step towards making ente your default gallery app!",
+        ),
+      );
+    }
 
     return Container(
       padding: const EdgeInsets.only(left: 16),

+ 74 - 77
lib/ui/payment/stripe_subscription_page.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 
+import "package:flutter/foundation.dart";
 import 'package:flutter/material.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/models/billing_plan.dart';
@@ -7,12 +8,15 @@ import 'package:photos/models/subscription.dart';
 import 'package:photos/models/user_details.dart';
 import 'package:photos/services/billing_service.dart';
 import 'package:photos/services/user_service.dart';
+import "package:photos/theme/colors.dart";
 import 'package:photos/theme/ente_theme.dart';
 import 'package:photos/ui/common/bottom_shadow.dart';
 import 'package:photos/ui/common/loading_widget.dart';
 import 'package:photos/ui/common/progress_dialog.dart';
 import 'package:photos/ui/common/web_page.dart';
 import 'package:photos/ui/components/button_widget.dart';
+import "package:photos/ui/components/captioned_text_widget.dart";
+import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
 import 'package:photos/ui/payment/child_subscription_widget.dart';
 import 'package:photos/ui/payment/payment_web_page.dart';
 import 'package:photos/ui/payment/skip_subscription_widget.dart';
@@ -50,6 +54,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
   bool _isLoading = false;
   bool _isStripeSubscriber = false;
   bool _showYearlyPlan = false;
+  EnteColorScheme colorScheme = darkScheme;
 
   @override
   void initState() {
@@ -112,6 +117,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
 
   @override
   Widget build(BuildContext context) {
+    colorScheme = getEnteColorScheme(context);
     final appBar = PreferredSize(
       preferredSize: const Size(double.infinity, 60),
       child: Container(
@@ -143,7 +149,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
               )
             : AppBar(
                 elevation: 0,
-                title: const Text("Subscription"),
+                title: const Text("Subscription${kDebugMode ? ' Stripe' : ''}"),
               ),
       ),
     );
@@ -205,7 +211,9 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
       if (widget.isOnboarding) {
         widgets.add(SkipSubscriptionWidget(freePlan: _freePlan));
       }
-      widgets.add(const SubFaqWidget());
+      widgets.add(
+        SubFaqWidget(isOnboarding: widget.isOnboarding),
+      );
     }
 
     // only active subscription can be renewed/canceled
@@ -214,90 +222,49 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
     }
 
     if (_currentSubscription!.productID != freeProductID) {
-      widgets.addAll([
-        Align(
-          alignment: Alignment.center,
-          child: GestureDetector(
+      widgets.add(
+        Padding(
+          padding: const EdgeInsets.fromLTRB(16, 40, 16, 4),
+          child: MenuItemWidget(
+            captionedTextWidget: const CaptionedTextWidget(
+              title: "Payment details",
+            ),
+            menuItemColor: colorScheme.fillFaint,
+            trailingWidget: Icon(
+              Icons.chevron_right_outlined,
+              color: colorScheme.strokeBase,
+            ),
+            singleBorderRadius: 4,
+            alignCaptionedTextToLeft: true,
             onTap: () async {
-              final String paymentProvider =
-                  _currentSubscription!.paymentProvider;
-              switch (_currentSubscription!.paymentProvider) {
-                case stripe:
-                  await _launchStripePortal();
-                  break;
-                case playStore:
-                  launchUrlString(
-                    "https://play.google.com/store/account/subscriptions?sku=" +
-                        _currentSubscription!.productID +
-                        "&package=io.ente.photos",
-                  );
-                  break;
-                case appStore:
-                  launchUrlString("https://apps.apple.com/account/billing");
-                  break;
-                default:
-                  final String capitalizedWord = paymentProvider.isNotEmpty
-                      ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}'
-                      : '';
-                  showErrorDialog(
-                    context,
-                    "Sorry",
-                    "Please contact us at support@ente.io to manage your "
-                        "$capitalizedWord subscription.",
-                  );
-              }
+              _onStripSupportedPaymentDetailsTap();
             },
-            child: Container(
-              padding: const EdgeInsets.fromLTRB(40, 80, 40, 20),
-              child: Column(
-                children: [
-                  RichText(
-                    text: TextSpan(
-                      text: "Payment details",
-                      style: TextStyle(
-                        color: Theme.of(context).colorScheme.onSurface,
-                        fontFamily: 'Inter-Medium',
-                        fontSize: 14,
-                        decoration: TextDecoration.underline,
-                      ),
-                    ),
-                    textAlign: TextAlign.center,
-                  ),
-                ],
-              ),
-            ),
           ),
         ),
-      ]);
+      );
     }
 
     if (!widget.isOnboarding) {
-      widgets.addAll([
-        Align(
-          alignment: Alignment.topCenter,
-          child: GestureDetector(
+      widgets.add(
+        Padding(
+          padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
+          child: MenuItemWidget(
+            captionedTextWidget: const CaptionedTextWidget(
+              title: "Manage Family",
+            ),
+            menuItemColor: colorScheme.fillFaint,
+            trailingWidget: Icon(
+              Icons.chevron_right_outlined,
+              color: colorScheme.strokeBase,
+            ),
+            singleBorderRadius: 4,
+            alignCaptionedTextToLeft: true,
             onTap: () async {
               _billingService.launchFamilyPortal(context, _userDetails);
             },
-            child: Container(
-              padding: const EdgeInsets.fromLTRB(40, 0, 40, 80),
-              child: Column(
-                children: [
-                  RichText(
-                    text: TextSpan(
-                      text: "Manage family",
-                      style: Theme.of(context).textTheme.bodyMedium!.copyWith(
-                            decoration: TextDecoration.underline,
-                          ),
-                    ),
-                    textAlign: TextAlign.center,
-                  ),
-                ],
-              ),
-            ),
           ),
         ),
-      ]);
+      );
     }
 
     return SingleChildScrollView(
@@ -308,6 +275,37 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
     );
   }
 
+  // _onStripSupportedPaymentDetailsTap action allows the user to update
+  // their stripe payment details
+  void _onStripSupportedPaymentDetailsTap() async {
+    final String paymentProvider = _currentSubscription!.paymentProvider;
+    switch (_currentSubscription!.paymentProvider) {
+      case stripe:
+        await _launchStripePortal();
+        break;
+      case playStore:
+        launchUrlString(
+          "https://play.google.com/store/account/subscriptions?sku=" +
+              _currentSubscription!.productID +
+              "&package=io.ente.photos",
+        );
+        break;
+      case appStore:
+        launchUrlString("https://apps.apple.com/account/billing");
+        break;
+      default:
+        final String capitalizedWord = paymentProvider.isNotEmpty
+            ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}'
+            : '';
+        showErrorDialog(
+          context,
+          "Sorry",
+          "Please contact us at support@ente.io to manage your "
+              "$capitalizedWord subscription.",
+        );
+    }
+  }
+
   Future<void> _launchStripePortal() async {
     await _dialog.show();
     try {
@@ -336,9 +334,8 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
         title,
         style: TextStyle(
           color: (isRenewCancelled
-                  ? Colors.greenAccent
-                  : Theme.of(context).colorScheme.onSurface)
-              .withOpacity(isRenewCancelled ? 1.0 : 0.2),
+              ? colorScheme.primary700
+              : colorScheme.textMuted),
         ),
       ),
       onPressed: () async {

+ 27 - 20
lib/ui/payment/subscription_common_widgets.dart

@@ -1,6 +1,9 @@
 import 'package:flutter/material.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/models/subscription.dart';
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/captioned_text_widget.dart";
+import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
 import 'package:photos/ui/payment/billing_questions_widget.dart';
 import 'package:photos/utils/data_util.dart';
 import 'package:photos/utils/date_time_util.dart';
@@ -38,11 +41,14 @@ class _SubscriptionHeaderWidgetState extends State<SubscriptionHeaderWidget> {
                 ),
               ],
             ),
-            const SizedBox(
-              height: 10,
+            const SizedBox(height: 10),
+            Text(
+              "ente preserves your memories, so they're always available to you, even if you lose your device.",
+              style: Theme.of(context).textTheme.caption,
             ),
+            const SizedBox(height: 4),
             Text(
-              "Ente preserves your memories, so they're always available to you, even if you lose your device ",
+              "Your family can be added to your plan as well. ",
               style: Theme.of(context).textTheme.caption,
             ),
           ],
@@ -107,15 +113,27 @@ class ValidityWidget extends StatelessWidget {
 }
 
 class SubFaqWidget extends StatelessWidget {
-  const SubFaqWidget({Key? key}) : super(key: key);
+  final bool isOnboarding;
+
+  const SubFaqWidget({Key? key, this.isOnboarding = false}) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
-    return Align(
-      alignment: Alignment.bottomCenter,
-      child: GestureDetector(
-        behavior: HitTestBehavior.translucent,
-        onTap: () {
+    final colorScheme = getEnteColorScheme(context);
+    return Padding(
+      padding: EdgeInsets.fromLTRB(16, 40, 16, isOnboarding ? 40 : 4),
+      child: MenuItemWidget(
+        captionedTextWidget: const CaptionedTextWidget(
+          title: "Questions?",
+        ),
+        menuItemColor: colorScheme.fillFaint,
+        trailingWidget: Icon(
+          Icons.chevron_right_outlined,
+          color: colorScheme.strokeBase,
+        ),
+        singleBorderRadius: 4,
+        alignCaptionedTextToLeft: true,
+        onTap: () async {
           showModalBottomSheet<void>(
             backgroundColor: Theme.of(context).colorScheme.bgColorForQuestions,
             barrierColor: Colors.black87,
@@ -125,17 +143,6 @@ class SubFaqWidget extends StatelessWidget {
             },
           );
         },
-        child: Container(
-          padding: const EdgeInsets.all(40),
-          child: RichText(
-            text: TextSpan(
-              text: "Questions?",
-              style: Theme.of(context).textTheme.bodyMedium!.copyWith(
-                    decoration: TextDecoration.underline,
-                  ),
-            ),
-          ),
-        ),
       ),
     );
   }

+ 92 - 78
lib/ui/payment/subscription_page.dart

@@ -1,6 +1,7 @@
 import 'dart:async';
 import 'dart:io';
 
+import "package:flutter/foundation.dart";
 import 'package:flutter/material.dart';
 import 'package:in_app_purchase/in_app_purchase.dart';
 import 'package:logging/logging.dart';
@@ -12,8 +13,12 @@ import 'package:photos/models/subscription.dart';
 import 'package:photos/models/user_details.dart';
 import 'package:photos/services/billing_service.dart';
 import 'package:photos/services/user_service.dart';
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
 import 'package:photos/ui/common/loading_widget.dart';
 import 'package:photos/ui/common/progress_dialog.dart';
+import "package:photos/ui/components/captioned_text_widget.dart";
+import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
 import 'package:photos/ui/payment/child_subscription_widget.dart';
 import 'package:photos/ui/payment/skip_subscription_widget.dart';
 import 'package:photos/ui/payment/subscription_common_widgets.dart';
@@ -48,6 +53,7 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
   bool _hasLoadedData = false;
   bool _isLoading = false;
   late bool _isActiveStripeSubscriber;
+  EnteColorScheme colorScheme = darkScheme;
 
   @override
   void initState() {
@@ -135,13 +141,16 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
 
   @override
   Widget build(BuildContext context) {
+    colorScheme = getEnteColorScheme(context);
     if (!_isLoading) {
       _isLoading = true;
       _fetchSubData();
     }
     _dialog = createProgressDialog(context, "Please wait...");
     final appBar = AppBar(
-      title: widget.isOnboarding ? null : const Text("Subscription"),
+      title: widget.isOnboarding
+          ? null
+          : const Text("Subscription${kDebugMode ? ' Store' : ''}"),
     );
     return Scaffold(
       appBar: appBar,
@@ -149,6 +158,11 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
     );
   }
 
+  bool _isFreePlanUser() {
+    return _currentSubscription != null &&
+        freeProductID == _currentSubscription!.productID;
+  }
+
   Future<void> _fetchSubData() async {
     _userService.getUserDetailsV2(memoryCount: false).then((userDetails) async {
       _userDetails = userDetails;
@@ -212,98 +226,69 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
       if (widget.isOnboarding) {
         widgets.add(SkipSubscriptionWidget(freePlan: _freePlan));
       }
-      widgets.add(const SubFaqWidget());
+      widgets.add(
+        SubFaqWidget(isOnboarding: widget.isOnboarding),
+      );
     }
 
     if (_hasActiveSubscription &&
         _currentSubscription!.productID != freeProductID) {
-      widgets.addAll([
-        Align(
-          alignment: Alignment.center,
-          child: GestureDetector(
-            onTap: () {
-              final String paymentProvider =
-                  _currentSubscription!.paymentProvider;
-              if (paymentProvider == appStore && !Platform.isAndroid) {
-                launchUrlString("https://apps.apple.com/account/billing");
-              } else if (paymentProvider == playStore && Platform.isAndroid) {
-                launchUrlString(
-                  "https://play.google.com/store/account/subscriptions?sku=" +
-                      _currentSubscription!.productID +
-                      "&package=io.ente.photos",
-                );
-              } else if (paymentProvider == stripe) {
-                showErrorDialog(
-                  context,
-                  "Sorry",
-                  "Visit web.ente.io to manage your subscription",
-                );
-              } else {
-                final String capitalizedWord = paymentProvider.isNotEmpty
-                    ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}'
-                    : '';
-                showErrorDialog(
-                  context,
-                  "Sorry",
-                  "Please contact us at support@ente.io to manage your "
-                      "$capitalizedWord subscription.",
-                );
-              }
-            },
-            child: Container(
-              padding: const EdgeInsets.fromLTRB(40, 80, 40, 20),
-              child: Column(
-                children: [
-                  RichText(
-                    text: TextSpan(
-                      text: _isActiveStripeSubscriber
-                          ? "Visit web.ente.io to manage your subscription"
-                          : "Payment details",
-                      style: TextStyle(
-                        color: Theme.of(context).colorScheme.onSurface,
-                        fontFamily: 'Inter-Medium',
-                        fontSize: 14,
-                        decoration: _isActiveStripeSubscriber
-                            ? TextDecoration.none
-                            : TextDecoration.underline,
-                      ),
-                    ),
-                    textAlign: TextAlign.center,
+      if (_isActiveStripeSubscriber) {
+        widgets.add(
+          Padding(
+            padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
+            child: Text(
+              "Visit web.ente.io to manage your subscription",
+              style: getEnteTextTheme(context).small.copyWith(
+                    color: colorScheme.textMuted,
                   ),
-                ],
+            ),
+          ),
+        );
+      } else {
+        widgets.add(
+          Padding(
+            padding: const EdgeInsets.fromLTRB(16, 40, 16, 4),
+            child: MenuItemWidget(
+              captionedTextWidget: const CaptionedTextWidget(
+                title: "Payment details",
+              ),
+              menuItemColor: colorScheme.fillFaint,
+              trailingWidget: Icon(
+                Icons.chevron_right_outlined,
+                color: colorScheme.strokeBase,
               ),
+              singleBorderRadius: 4,
+              alignCaptionedTextToLeft: true,
+              onTap: () async {
+                _onPlatformRestrictedPaymentDetailsClick();
+              },
             ),
           ),
-        ),
-      ]);
+        );
+      }
     }
     if (!widget.isOnboarding) {
-      widgets.addAll([
-        Align(
-          alignment: Alignment.topCenter,
-          child: GestureDetector(
+      widgets.add(
+        Padding(
+          padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
+          child: MenuItemWidget(
+            captionedTextWidget: CaptionedTextWidget(
+              title: _isFreePlanUser() ? "Family Plans" : "Manage Family",
+            ),
+            menuItemColor: colorScheme.fillFaint,
+            trailingWidget: Icon(
+              Icons.chevron_right_outlined,
+              color: colorScheme.strokeBase,
+            ),
+            singleBorderRadius: 4,
+            alignCaptionedTextToLeft: true,
             onTap: () async {
               _billingService.launchFamilyPortal(context, _userDetails);
             },
-            child: Container(
-              padding: const EdgeInsets.fromLTRB(40, 0, 40, 80),
-              child: Column(
-                children: [
-                  RichText(
-                    text: TextSpan(
-                      text: "Manage family",
-                      style: Theme.of(context).textTheme.bodyMedium!.copyWith(
-                            decoration: TextDecoration.underline,
-                          ),
-                    ),
-                    textAlign: TextAlign.center,
-                  ),
-                ],
-              ),
-            ),
           ),
         ),
-      ]);
+      );
     }
     return SingleChildScrollView(
       child: Column(
@@ -313,6 +298,35 @@ class _SubscriptionPageState extends State<SubscriptionPage> {
     );
   }
 
+  void _onPlatformRestrictedPaymentDetailsClick() {
+    final String paymentProvider = _currentSubscription!.paymentProvider;
+    if (paymentProvider == appStore && !Platform.isAndroid) {
+      launchUrlString("https://apps.apple.com/account/billing");
+    } else if (paymentProvider == playStore && Platform.isAndroid) {
+      launchUrlString(
+        "https://play.google.com/store/account/subscriptions?sku=" +
+            _currentSubscription!.productID +
+            "&package=io.ente.photos",
+      );
+    } else if (paymentProvider == stripe) {
+      showErrorDialog(
+        context,
+        "Sorry",
+        "Visit web.ente.io to manage your subscription",
+      );
+    } else {
+      final String capitalizedWord = paymentProvider.isNotEmpty
+          ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}'
+          : '';
+      showErrorDialog(
+        context,
+        "Sorry",
+        "Please contact us at support@ente.io to manage your "
+            "$capitalizedWord subscription.",
+      );
+    }
+  }
+
   List<Widget> _getStripePlanWidgets() {
     final List<Widget> planWidgets = [];
     bool foundActivePlan = false;

+ 17 - 0
lib/ui/settings/general_section_widget.dart

@@ -6,6 +6,7 @@ import 'package:photos/ui/advanced_settings_screen.dart';
 import 'package:photos/ui/components/captioned_text_widget.dart';
 import 'package:photos/ui/components/expandable_menu_item_widget.dart';
 import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
+import "package:photos/ui/growth/referral_screen.dart";
 import 'package:photos/ui/payment/subscription.dart';
 import 'package:photos/ui/settings/common_settings.dart';
 import 'package:photos/utils/navigation_util.dart';
@@ -51,6 +52,22 @@ class GeneralSectionWidget extends StatelessWidget {
           },
         ),
         sectionOptionSpacing,
+        MenuItemWidget(
+          captionedTextWidget: const CaptionedTextWidget(
+            title: "Referrals",
+          ),
+          pressedColor: getEnteColorScheme(context).fillFaint,
+          trailingIcon: Icons.chevron_right_outlined,
+          trailingIconIsMuted: true,
+          onTap: () async {
+            routeToPage(
+              context,
+              const ReferralScreen(),
+              forceCustomPageRoute: true,
+            );
+          },
+        ),
+        sectionOptionSpacing,
         MenuItemWidget(
           captionedTextWidget: const CaptionedTextWidget(
             title: "Advanced",

+ 21 - 1
lib/ui/settings_page.dart

@@ -6,8 +6,11 @@ import 'package:photos/core/configuration.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/events/opened_settings_event.dart';
 import 'package:photos/services/feature_flag_service.dart';
+import "package:photos/services/storage_bonus_service.dart";
 import 'package:photos/theme/colors.dart';
 import 'package:photos/theme/ente_theme.dart';
+import "package:photos/ui/components/notification_widget.dart";
+import "package:photos/ui/growth/referral_screen.dart";
 import 'package:photos/ui/settings/about_section_widget.dart';
 import 'package:photos/ui/settings/account_section_widget.dart';
 import 'package:photos/ui/settings/app_version_widget.dart';
@@ -21,9 +24,11 @@ import 'package:photos/ui/settings/social_section_widget.dart';
 import 'package:photos/ui/settings/storage_card_widget.dart';
 import 'package:photos/ui/settings/support_section_widget.dart';
 import 'package:photos/ui/settings/theme_switch_widget.dart';
+import "package:photos/utils/navigation_util.dart";
 
 class SettingsPage extends StatelessWidget {
   final ValueNotifier<String?> emailNotifier;
+
   const SettingsPage({Key? key, required this.emailNotifier}) : super(key: key);
 
   @override
@@ -71,7 +76,22 @@ class SettingsPage extends StatelessWidget {
     if (hasLoggedIn) {
       contents.addAll([
         const StorageCardWidget(),
-        const SizedBox(height: 12),
+        StorageBonusService.instance.shouldShowStorageBonus()
+            ? Padding(
+                padding: const EdgeInsets.symmetric(vertical: 8.0),
+                child: NotificationWidget(
+                  startIcon: Icons.auto_awesome,
+                  actionIcon: Icons.arrow_forward_outlined,
+                  text: "Double your storage",
+                  subText: "Refer friends and 2x your plan",
+                  type: NotificationType.banner,
+                  onTap: () async {
+                    StorageBonusService.instance.markStorageBonusAsDone();
+                    routeToPage(context, const ReferralScreen());
+                  },
+                ),
+              )
+            : const SizedBox(height: 12),
         const BackupSectionWidget(),
         sectionSpacing,
         const AccountSectionWidget(),

+ 11 - 8
lib/ui/viewer/file/file_info_widget.dart

@@ -10,6 +10,7 @@ import "package:photos/ente_theme_data.dart";
 import "package:photos/models/file.dart";
 import "package:photos/models/file_type.dart";
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/feature_flag_service.dart";
 import 'package:photos/theme/ente_theme.dart';
 import 'package:photos/ui/components/divider_widget.dart';
 import 'package:photos/ui/components/icon_button_widget.dart';
@@ -236,14 +237,16 @@ class _FileInfoWidgetState extends State<FileInfoWidget> {
               : DeviceFoldersListOfFileWidget(allDeviceFoldersOfFile),
         ),
       ),
-      SizedBox(
-        height: 62,
-        child: ListTile(
-          horizontalTitleGap: 0,
-          leading: const Icon(Icons.image_search),
-          title: ObjectTagsWidget(file),
-        ),
-      ),
+      FeatureFlagService.instance.isInternalUserOrDebugBuild()
+          ? SizedBox(
+              height: 62,
+              child: ListTile(
+                horizontalTitleGap: 0,
+                leading: const Icon(Icons.image_search),
+                title: ObjectTagsWidget(file),
+              ),
+            )
+          : null,
       (file.uploadedFileID != null && file.updationTime != null)
           ? ListTile(
               horizontalTitleGap: 2,

+ 47 - 0
lib/ui/viewer/gallery/archive_page.dart

@@ -1,14 +1,19 @@
 import 'package:collection/collection.dart' show IterableExtension;
 import 'package:flutter/material.dart';
+import "package:logging/logging.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/events/files_updated_event.dart';
+import "package:photos/models/collection_items.dart";
 import 'package:photos/models/gallery_type.dart';
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/models/selected_files.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/ui/collections/collection_item_widget.dart";
+import "package:photos/ui/common/loading_widget.dart";
 import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart';
+import "package:photos/ui/viewer/gallery/empty_state.dart";
 import 'package:photos/ui/viewer/gallery/gallery.dart';
 import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart';
 
@@ -17,6 +22,7 @@ class ArchivePage extends StatelessWidget {
   final GalleryType appBarType;
   final GalleryType overlayType;
   final _selectedFiles = SelectedFiles();
+  final Logger _logger = Logger("ArchivePage");
 
   ArchivePage({
     this.tagPrefix = "archived_page",
@@ -61,6 +67,47 @@ class ArchivePage extends StatelessWidget {
       tagPrefix: tagPrefix,
       selectedFiles: _selectedFiles,
       initialFiles: null,
+      emptyState: const EmptyState(
+        text: "You don't have any archived items.",
+      ),
+      header: FutureBuilder(
+        future: CollectionsService.instance.getArchivedCollectionWithThumb(),
+        builder: (context, snapshot) {
+          if (snapshot.hasError) {
+            _logger.severe("failed to fetch archived albums", snapshot.error);
+            return const Text("Something went wrong");
+          } else if (snapshot.hasData) {
+            final collectionsWithThumbnail =
+                snapshot.data as List<CollectionWithThumbnail>;
+            return SizedBox(
+              height: 200,
+              child: ListView.builder(
+                shrinkWrap: true,
+                scrollDirection: Axis.horizontal,
+                itemCount: collectionsWithThumbnail.length,
+                padding: const EdgeInsets.fromLTRB(6, 6, 6, 6),
+                itemBuilder: (context, index) {
+                  final item = collectionsWithThumbnail[index];
+                  return GestureDetector(
+                    behavior: HitTestBehavior.opaque,
+                    onTap: () async {},
+                    child: Padding(
+                      padding: const EdgeInsets.all(2.0),
+                      child: CollectionItem(
+                        item,
+                        120,
+                        shouldRender: true,
+                      ),
+                    ),
+                  );
+                },
+              ),
+            );
+          } else {
+            return const EnteLoadingWidget();
+          }
+        },
+      ),
     );
     return Scaffold(
       appBar: PreferredSize(

+ 4 - 0
lib/utils/data_util.dart

@@ -35,6 +35,10 @@ num convertBytesToGBs(int bytes) {
   return num.parse((bytes / (pow(1024, 3))).toStringAsFixed(1));
 }
 
+int convertBytesToAbsoluteGBs(int bytes) {
+  return (bytes / pow(1024, 3)).round();
+}
+
 int convertBytesToMBs(int bytes) {
   return (bytes / pow(1024, 2)).round();
 }

+ 30 - 0
lib/utils/dialog_util.dart

@@ -1,6 +1,7 @@
 import 'dart:math';
 
 import 'package:confetti/confetti.dart';
+import "package:dio/dio.dart";
 import 'package:flutter/material.dart';
 import 'package:photos/core/constants.dart';
 import "package:photos/models/search/button_result.dart";
@@ -38,6 +39,35 @@ Future<ButtonResult?> showErrorDialog(
   );
 }
 
+Future<ButtonResult?> showErrorDialogForException({
+  required BuildContext context,
+  required Exception exception,
+  bool isDismissible = true,
+}) async {
+  String errorMessage =
+      "It looks like something went wrong. Please retry after some time. If the error persists, please contact our support team.";
+  if (exception is DioError &&
+      exception.response != null &&
+      exception.response!.data["code"] != null) {
+    errorMessage = "It looks like something went wrong. \n\nReason: " +
+        exception.response!.data["code"];
+  }
+  return showDialogWidget(
+    context: context,
+    title: "Error",
+    icon: Icons.error_outline_outlined,
+    body: errorMessage,
+    isDismissible: isDismissible,
+    buttons: const [
+      ButtonWidget(
+        buttonType: ButtonType.secondary,
+        labelText: "OK",
+        isInAlert: true,
+      ),
+    ],
+  );
+}
+
 ///Will return null if dismissed by tapping outside
 Future<ButtonResult?> showGenericErrorDialog({
   required BuildContext context,

+ 2 - 2
pubspec.lock

@@ -952,10 +952,10 @@ packages:
     dependency: "direct main"
     description:
       name: media_extension
-      sha256: "50d9f5dd1b31296b0f5bf63c92b6dd69f5a59d9cd1d984f53834f51176319bb0"
+      sha256: "41e60ac3c4367060922c5b667f47768301d259fd94bc3122c263b266a167d9fc"
       url: "https://pub.dev"
     source: hosted
-    version: "1.0.0"
+    version: "1.0.1"
   meta:
     dependency: transitive
     description:

+ 1 - 1
pubspec.yaml

@@ -12,7 +12,7 @@ description: ente photos application
 # Read more about iOS versioning at
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 
-version: 0.7.20+420
+version: 0.7.26+426
 
 environment:
   sdk: '>=2.17.0 <3.0.0'