Resolved merge conflicts
This commit is contained in:
commit
681be7b435
112 changed files with 3382 additions and 1499 deletions
33
.github/workflows/crowdin.yml
vendored
Normal file
33
.github/workflows/crowdin.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
name: Sync crowdin translation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'lib/l10n/app_en.arb'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_translations
|
||||
create_pull_request: true
|
||||
skip_untranslated_strings: true
|
||||
pull_request_title: 'New Translations'
|
||||
pull_request_body: 'New translations via [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
|
@ -66,7 +66,7 @@ You can alternatively install the build from PlayStore or F-Droid.
|
|||
3. Pull in all submodules with `git submodule update --init --recursive`
|
||||
4. Enable repo git hooks `git config core.hooksPath hooks`
|
||||
5. Setup TensorFlowLite by executing `setup.sh`
|
||||
6. For Android, run `flutter build apk --release --flavor independent`
|
||||
6. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent`
|
||||
7. For iOS, run `flutter build ios`
|
||||
|
||||
<br/>
|
||||
|
|
1001
assets/models/mobilenet/labels_mobilenet_quant_v1_224.txt
Normal file
1001
assets/models/mobilenet/labels_mobilenet_quant_v1_224.txt
Normal file
File diff suppressed because it is too large
Load diff
BIN
assets/models/mobilenet/mobilenet_v1_1.0_224_quant.tflite
Normal file
BIN
assets/models/mobilenet/mobilenet_v1_1.0_224_quant.tflite
Normal file
Binary file not shown.
30
assets/models/scenes/labels.txt
Normal file
30
assets/models/scenes/labels.txt
Normal file
|
@ -0,0 +1,30 @@
|
|||
waterfall
|
||||
snow
|
||||
landscape
|
||||
underwater
|
||||
architecture
|
||||
sunset / sunrise
|
||||
blue sky
|
||||
cloudy sky
|
||||
greenery
|
||||
autumn leaves
|
||||
potrait
|
||||
flower
|
||||
night shot
|
||||
stage concert
|
||||
fireworks
|
||||
candle light
|
||||
neon lights
|
||||
indoor
|
||||
backlight
|
||||
text documents
|
||||
qr images
|
||||
group potrait
|
||||
computer screens
|
||||
kids
|
||||
dog
|
||||
cat
|
||||
macro
|
||||
food
|
||||
beach
|
||||
mountain
|
BIN
assets/models/scenes/model.tflite
Normal file
BIN
assets/models/scenes/model.tflite
Normal file
Binary file not shown.
6
crowdin.yml
Normal file
6
crowdin.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
|
||||
files:
|
||||
- source: /lib/l10n/app_en.arb
|
||||
translation: /lib/l10n/app_%two_letters_code%.arb
|
|
@ -108,7 +108,7 @@ PODS:
|
|||
- libwebp/mux (1.2.4):
|
||||
- libwebp/demux
|
||||
- libwebp/webp (1.2.4)
|
||||
- local_auth (0.0.1):
|
||||
- local_auth_ios (0.0.1):
|
||||
- Flutter
|
||||
- Mantle (2.2.0):
|
||||
- Mantle/extobjc (= 2.2.0)
|
||||
|
@ -195,7 +195,7 @@ DEPENDENCIES:
|
|||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/ios`)
|
||||
- local_auth (from `.symlinks/plugins/local_auth/ios`)
|
||||
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
|
||||
- media_extension (from `.symlinks/plugins/media_extension/ios`)
|
||||
- motionphoto (from `.symlinks/plugins/motionphoto/ios`)
|
||||
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
|
||||
|
@ -276,8 +276,8 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/image_editor_common/ios"
|
||||
in_app_purchase_storekit:
|
||||
:path: ".symlinks/plugins/in_app_purchase_storekit/ios"
|
||||
local_auth:
|
||||
:path: ".symlinks/plugins/local_auth/ios"
|
||||
local_auth_ios:
|
||||
:path: ".symlinks/plugins/local_auth_ios/ios"
|
||||
media_extension:
|
||||
:path: ".symlinks/plugins/media_extension/ios"
|
||||
motionphoto:
|
||||
|
@ -346,7 +346,7 @@ SPEC CHECKSUMS:
|
|||
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
|
||||
in_app_purchase_storekit: 6b297e2b5eab9fa3251a492d57301722e4132a71
|
||||
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
|
||||
local_auth: 1740f55d7af0a2e2a8684ce225fe79d8931e808c
|
||||
local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
media_extension: 6d30dc1431ebaa63f43c397c37917b1a0a597a4c
|
||||
motionphoto: d4a432b8c8f22fb3ad966258597c0103c9c5ff16
|
||||
|
|
|
@ -290,7 +290,7 @@
|
|||
"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/local_auth/local_auth.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/media_extension/media_extension.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/motionphoto/motionphoto.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/move_to_background/move_to_background.framework",
|
||||
|
@ -346,7 +346,7 @@
|
|||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_extension.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/motionphoto.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/move_to_background.framework",
|
||||
|
|
|
@ -18,10 +18,12 @@ import "package:photos/utils/intent_util.dart";
|
|||
class EnteApp extends StatefulWidget {
|
||||
final Future<void> Function(String) runBackgroundTask;
|
||||
final Future<void> Function(String) killBackgroundTask;
|
||||
final AdaptiveThemeMode? savedThemeMode;
|
||||
|
||||
const EnteApp(
|
||||
this.runBackgroundTask,
|
||||
this.killBackgroundTask, {
|
||||
this.killBackgroundTask,
|
||||
this.savedThemeMode, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -56,7 +58,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
|||
return AdaptiveTheme(
|
||||
light: lightThemeData,
|
||||
dark: darkThemeData,
|
||||
initial: AdaptiveThemeMode.system,
|
||||
initial: widget.savedThemeMode ?? AdaptiveThemeMode.system,
|
||||
builder: (lightTheme, dartTheme) => MaterialApp(
|
||||
title: "ente",
|
||||
themeMode: ThemeMode.system,
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import "package:logging/logging.dart";
|
||||
|
||||
import 'package:photos/events/event.dart';
|
||||
|
||||
class SyncStatusUpdate extends Event {
|
||||
static final _logger = Logger("SyncStatusUpdate");
|
||||
|
||||
final SyncStatus status;
|
||||
final int? completed;
|
||||
final int? total;
|
||||
|
@ -18,6 +22,7 @@ class SyncStatusUpdate extends Event {
|
|||
this.reason = "",
|
||||
this.error,
|
||||
}) {
|
||||
_logger.info("Creating sync status: " + status.toString());
|
||||
timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
}
|
||||
}
|
||||
|
|
1
lib/l10n/app_de.arb
Normal file
1
lib/l10n/app_de.arb
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
6
lib/l10n/app_fr.arb
Normal file
6
lib/l10n/app_fr.arb
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"sign_up": "inscription",
|
||||
"@sign_up": {
|
||||
"description": "Text on the sign up button used during registration"
|
||||
}
|
||||
}
|
1
lib/l10n/app_it.arb
Normal file
1
lib/l10n/app_it.arb
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
1
lib/l10n/app_nl.arb
Normal file
1
lib/l10n/app_nl.arb
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import "package:adaptive_theme/adaptive_theme.dart";
|
||||
import 'package:background_fetch/background_fetch.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -58,28 +59,38 @@ const kBackgroundLockLatency = Duration(seconds: 3);
|
|||
void main() async {
|
||||
debugRepaintRainbowEnabled = false;
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await _runInForeground();
|
||||
final savedThemeMode = await AdaptiveTheme.getThemeMode();
|
||||
await _runInForeground(savedThemeMode);
|
||||
BackgroundFetch.registerHeadlessTask(_headlessTaskHandler);
|
||||
}
|
||||
|
||||
Future<void> _runInForeground() async {
|
||||
Future<void> _runInForeground(AdaptiveThemeMode? savedThemeMode) async {
|
||||
return await _runWithLogs(() async {
|
||||
_logger.info("Starting app in foreground");
|
||||
await _init(false, via: 'mainMethod');
|
||||
unawaited(_scheduleFGSync('appStart in FG'));
|
||||
runApp(
|
||||
AppLock(
|
||||
builder: (args) => const EnteApp(_runBackgroundTask, _killBGTask),
|
||||
builder: (args) =>
|
||||
EnteApp(_runBackgroundTask, _killBGTask, savedThemeMode),
|
||||
lockScreen: const LockScreen(),
|
||||
enabled: Configuration.instance.shouldShowLockScreen(),
|
||||
lightTheme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
backgroundLockLatency: kBackgroundLockLatency,
|
||||
savedThemeMode: _themeMode(savedThemeMode),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ThemeMode _themeMode(AdaptiveThemeMode? savedThemeMode) {
|
||||
if (savedThemeMode == null) return ThemeMode.system;
|
||||
if (savedThemeMode.isLight) return ThemeMode.light;
|
||||
if (savedThemeMode.isDark) return ThemeMode.dark;
|
||||
return ThemeMode.system;
|
||||
}
|
||||
|
||||
Future<void> _runBackgroundTask(String taskId, {String mode = 'normal'}) async {
|
||||
if (_isProcessRunning) {
|
||||
_logger.info("Background task triggered when process was already running");
|
||||
|
@ -119,7 +130,7 @@ Future<void> _runInBackground(String taskId) async {
|
|||
// https://stackoverflow.com/a/73796478/546896
|
||||
@pragma('vm:entry-point')
|
||||
void _headlessTaskHandler(HeadlessTask task) {
|
||||
print("_headlessTaskHandler");
|
||||
debugPrint("_headlessTaskHandler");
|
||||
if (task.timeout) {
|
||||
BackgroundFetch.finish(task.taskId);
|
||||
} else {
|
||||
|
|
|
@ -131,7 +131,8 @@ class BonusDetails {
|
|||
|
||||
factory BonusDetails.fromJson(Map<String, dynamic> json) => BonusDetails(
|
||||
referralStats: List<ReferralStat>.from(
|
||||
json["referralStats"].map((x) => ReferralStat.fromJson(x))),
|
||||
json["referralStats"].map((x) => ReferralStat.fromJson(x)),
|
||||
),
|
||||
bonuses:
|
||||
List<Bonus>.from(json["bonuses"].map((x) => Bonus.fromJson(x))),
|
||||
refCount: json["refCount"],
|
||||
|
|
|
@ -11,6 +11,10 @@ class DeviceCollection {
|
|||
int? collectionID;
|
||||
File? thumbnail;
|
||||
|
||||
bool hasCollectionID() {
|
||||
return collectionID != null && collectionID! != -1;
|
||||
}
|
||||
|
||||
DeviceCollection(
|
||||
this.id,
|
||||
this.name, {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import "package:photos/ui/components/button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
|
||||
class ButtonResult {
|
||||
///action can be null when action for the button that is returned when popping
|
||||
|
|
|
@ -9,6 +9,7 @@ class UserDetails {
|
|||
final String email;
|
||||
final int usage;
|
||||
final int fileCount;
|
||||
final int storageBonus;
|
||||
final int sharedCollectionsCount;
|
||||
final Subscription subscription;
|
||||
final FamilyData? familyData;
|
||||
|
@ -17,6 +18,7 @@ class UserDetails {
|
|||
this.email,
|
||||
this.usage,
|
||||
this.fileCount,
|
||||
this.storageBonus,
|
||||
this.sharedCollectionsCount,
|
||||
this.subscription,
|
||||
this.familyData,
|
||||
|
@ -50,7 +52,8 @@ class UserDetails {
|
|||
}
|
||||
|
||||
int getTotalStorage() {
|
||||
return isPartOfFamily() ? familyData!.storage : subscription.storage;
|
||||
return (isPartOfFamily() ? familyData!.storage : subscription.storage) +
|
||||
storageBonus;
|
||||
}
|
||||
|
||||
factory UserDetails.fromMap(Map<String, dynamic> map) {
|
||||
|
@ -58,6 +61,7 @@ class UserDetails {
|
|||
map['email'] as String,
|
||||
map['usage'] as int,
|
||||
(map['fileCount'] ?? 0) as int,
|
||||
(map['storageBonus'] ?? 0) as int,
|
||||
(map['sharedCollectionsCount'] ?? 0) as int,
|
||||
Subscription.fromMap(map['subscription']),
|
||||
FamilyData.fromMap(map['familyData']),
|
||||
|
@ -69,6 +73,7 @@ class UserDetails {
|
|||
'email': email,
|
||||
'usage': usage,
|
||||
'fileCount': fileCount,
|
||||
'storageBonus': storageBonus,
|
||||
'sharedCollectionsCount': sharedCollectionsCount,
|
||||
'subscription': subscription.toMap(),
|
||||
'familyData': familyData?.toMap(),
|
||||
|
|
|
@ -141,6 +141,7 @@ class CollectionsService {
|
|||
for (final collection in collections) {
|
||||
_cacheCollectionAttributes(collection);
|
||||
}
|
||||
_logger.info("Collections synced");
|
||||
watch.log("collection cache refresh");
|
||||
if (fetchedCollections.isNotEmpty) {
|
||||
Bus.instance.fire(
|
||||
|
|
|
@ -137,7 +137,10 @@ class FavoritesService {
|
|||
}
|
||||
|
||||
Future<void> updateFavorites(
|
||||
BuildContext context, List<File> files, bool favFlag) async {
|
||||
BuildContext context,
|
||||
List<File> files,
|
||||
bool favFlag,
|
||||
) async {
|
||||
final int currentUserID = Configuration.instance.getUserID()!;
|
||||
if (files.any((f) => f.uploadedFileID == null)) {
|
||||
throw AssertionError("Can only favorite uploaded items");
|
||||
|
|
|
@ -14,6 +14,12 @@ class FeatureFlagService {
|
|||
static final FeatureFlagService instance =
|
||||
FeatureFlagService._privateConstructor();
|
||||
static const _featureFlagsKey = "feature_flags_key";
|
||||
static final _internalUserIDs = const String.fromEnvironment(
|
||||
"internal_user_ids",
|
||||
defaultValue: "1,2,3,4,191",
|
||||
).split(",").map((element) {
|
||||
return int.parse(element);
|
||||
}).toSet();
|
||||
|
||||
final _logger = Logger("FeatureFlagService");
|
||||
FeatureFlags? _featureFlags;
|
||||
|
@ -64,7 +70,10 @@ class FeatureFlagService {
|
|||
|
||||
bool isInternalUserOrDebugBuild() {
|
||||
final String? email = Configuration.instance.getEmail();
|
||||
return (email != null && email.endsWith("@ente.io")) || kDebugMode;
|
||||
final userID = Configuration.instance.getUserID();
|
||||
return (email != null && email.endsWith("@ente.io")) ||
|
||||
_internalUserIDs.contains(userID) ||
|
||||
kDebugMode;
|
||||
}
|
||||
|
||||
Future<void> fetchFeatureFlags() async {
|
||||
|
|
|
@ -4,18 +4,20 @@ import "dart:typed_data";
|
|||
import "package:logging/logging.dart";
|
||||
import "package:photos/services/object_detection/models/predictions.dart";
|
||||
import 'package:photos/services/object_detection/models/recognition.dart';
|
||||
import "package:photos/services/object_detection/tflite/classifier.dart";
|
||||
import 'package:photos/services/object_detection/tflite/cocossd_classifier.dart';
|
||||
import "package:photos/services/object_detection/tflite/mobilenet_classifier.dart";
|
||||
import "package:photos/services/object_detection/tflite/scene_classifier.dart";
|
||||
import "package:photos/services/object_detection/utils/isolate_utils.dart";
|
||||
|
||||
class ObjectDetectionService {
|
||||
static const scoreThreshold = 0.6;
|
||||
static const scoreThreshold = 0.5;
|
||||
|
||||
final _logger = Logger("ObjectDetectionService");
|
||||
|
||||
/// Instance of [ObjectClassifier]
|
||||
late ObjectClassifier _classifier;
|
||||
late CocoSSDClassifier _objectClassifier;
|
||||
late MobileNetClassifier _mobileNetClassifier;
|
||||
late SceneClassifier _sceneClassifier;
|
||||
|
||||
/// Instance of [IsolateUtils]
|
||||
late IsolateUtils _isolateUtils;
|
||||
|
||||
ObjectDetectionService._privateConstructor();
|
||||
|
@ -23,7 +25,9 @@ class ObjectDetectionService {
|
|||
Future<void> init() async {
|
||||
_isolateUtils = IsolateUtils();
|
||||
await _isolateUtils.start();
|
||||
_classifier = ObjectClassifier();
|
||||
_objectClassifier = CocoSSDClassifier();
|
||||
_mobileNetClassifier = MobileNetClassifier();
|
||||
_sceneClassifier = SceneClassifier();
|
||||
}
|
||||
|
||||
static ObjectDetectionService instance =
|
||||
|
@ -31,18 +35,10 @@ class ObjectDetectionService {
|
|||
|
||||
Future<List<String>> predict(Uint8List bytes) async {
|
||||
try {
|
||||
final isolateData = IsolateData(
|
||||
bytes,
|
||||
_classifier.interpreter.address,
|
||||
_classifier.labels,
|
||||
);
|
||||
final predictions = await _inference(isolateData);
|
||||
final Set<String> results = {};
|
||||
for (final Recognition result in predictions.recognitions) {
|
||||
if (result.score > scoreThreshold) {
|
||||
results.add(result.label);
|
||||
}
|
||||
}
|
||||
final results = <String>{};
|
||||
results.addAll(await _getObjects(bytes));
|
||||
results.addAll(await _getMobileNetResults(bytes));
|
||||
results.addAll(await _getSceneResults(bytes));
|
||||
return results.toList();
|
||||
} catch (e, s) {
|
||||
_logger.severe(e, s);
|
||||
|
@ -50,6 +46,54 @@ class ObjectDetectionService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _getObjects(Uint8List bytes) async {
|
||||
final isolateData = IsolateData(
|
||||
bytes,
|
||||
_objectClassifier.interpreter.address,
|
||||
_objectClassifier.labels,
|
||||
ClassifierType.cocossd,
|
||||
);
|
||||
return _getPredictions(isolateData);
|
||||
}
|
||||
|
||||
Future<List<String>> _getMobileNetResults(Uint8List bytes) async {
|
||||
final isolateData = IsolateData(
|
||||
bytes,
|
||||
_mobileNetClassifier.interpreter.address,
|
||||
_mobileNetClassifier.labels,
|
||||
ClassifierType.mobilenet,
|
||||
);
|
||||
return _getPredictions(isolateData);
|
||||
}
|
||||
|
||||
Future<List<String>> _getSceneResults(Uint8List bytes) async {
|
||||
final isolateData = IsolateData(
|
||||
bytes,
|
||||
_sceneClassifier.interpreter.address,
|
||||
_sceneClassifier.labels,
|
||||
ClassifierType.scenes,
|
||||
);
|
||||
return _getPredictions(isolateData);
|
||||
}
|
||||
|
||||
Future<List<String>> _getPredictions(IsolateData isolateData) async {
|
||||
final predictions = await _inference(isolateData);
|
||||
final Set<String> results = {};
|
||||
for (final Recognition result in predictions.recognitions) {
|
||||
if (result.score > scoreThreshold) {
|
||||
results.add(result.label);
|
||||
}
|
||||
}
|
||||
_logger.info(
|
||||
"Time taken for " +
|
||||
isolateData.type.toString() +
|
||||
": " +
|
||||
predictions.stats.totalElapsedTime.toString() +
|
||||
"ms",
|
||||
);
|
||||
return results.toList();
|
||||
}
|
||||
|
||||
/// Runs inference in another isolate
|
||||
Future<Predictions> _inference(IsolateData isolateData) async {
|
||||
final responsePort = ReceivePort();
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
import 'dart:math';
|
||||
import "dart:math";
|
||||
|
||||
import 'package:image/image.dart' as imageLib;
|
||||
import 'package:image/image.dart' as image_lib;
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/services/object_detection/models/predictions.dart';
|
||||
import 'package:photos/services/object_detection/models/recognition.dart';
|
||||
import "package:photos/services/object_detection/models/stats.dart";
|
||||
import "package:photos/services/object_detection/models/predictions.dart";
|
||||
import "package:tflite_flutter/tflite_flutter.dart";
|
||||
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
|
||||
|
||||
/// Classifier
|
||||
class ObjectClassifier {
|
||||
final _logger = Logger("Classifier");
|
||||
abstract class Classifier {
|
||||
// Path to the model
|
||||
String get modelPath;
|
||||
|
||||
// Path to the labels
|
||||
String get labelPath;
|
||||
|
||||
// Input size expected by the model (for eg. width = height = 224)
|
||||
int get inputSize;
|
||||
|
||||
// Logger implementation for the specific classifier
|
||||
Logger get logger;
|
||||
|
||||
Predictions? predict(image_lib.Image image);
|
||||
|
||||
/// Instance of Interpreter
|
||||
late Interpreter _interpreter;
|
||||
|
@ -18,44 +27,30 @@ class ObjectClassifier {
|
|||
/// Labels file loaded as list
|
||||
late List<String> _labels;
|
||||
|
||||
/// Input size of image (height = width = 300)
|
||||
static const int inputSize = 300;
|
||||
|
||||
/// Result score threshold
|
||||
static const double threshold = 0.5;
|
||||
|
||||
static const String modelFileName = "detect.tflite";
|
||||
static const String labelFileName = "labelmap.txt";
|
||||
|
||||
/// [ImageProcessor] used to pre-process the image
|
||||
ImageProcessor? imageProcessor;
|
||||
|
||||
/// Padding the image to transform into square
|
||||
late int padSize;
|
||||
|
||||
/// Shapes of output tensors
|
||||
late List<List<int>> _outputShapes;
|
||||
|
||||
/// Types of output tensors
|
||||
late List<TfLiteType> _outputTypes;
|
||||
|
||||
/// Number of results to show
|
||||
static const int numResults = 10;
|
||||
/// Gets the interpreter instance
|
||||
Interpreter get interpreter => _interpreter;
|
||||
|
||||
ObjectClassifier({
|
||||
Interpreter? interpreter,
|
||||
List<String>? labels,
|
||||
}) {
|
||||
loadModel(interpreter);
|
||||
loadLabels(labels);
|
||||
}
|
||||
/// Gets the loaded labels
|
||||
List<String> get labels => _labels;
|
||||
|
||||
/// Gets the output shapes
|
||||
List<List<int>> get outputShapes => _outputShapes;
|
||||
|
||||
/// Gets the output types
|
||||
List<TfLiteType> get outputTypes => _outputTypes;
|
||||
|
||||
/// Loads interpreter from asset
|
||||
void loadModel(Interpreter? interpreter) async {
|
||||
try {
|
||||
_interpreter = interpreter ??
|
||||
await Interpreter.fromAsset(
|
||||
"models/" + modelFileName,
|
||||
modelPath,
|
||||
options: InterpreterOptions()..threads = 4,
|
||||
);
|
||||
final outputTensors = _interpreter.getOutputTensors();
|
||||
|
@ -65,115 +60,30 @@ class ObjectClassifier {
|
|||
_outputShapes.add(tensor.shape);
|
||||
_outputTypes.add(tensor.type);
|
||||
});
|
||||
_logger.info("Interpreter initialized");
|
||||
logger.info("Interpreter initialized");
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error while creating interpreter", e, s);
|
||||
logger.severe("Error while creating interpreter", e, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads labels from assets
|
||||
void loadLabels(List<String>? labels) async {
|
||||
try {
|
||||
_labels =
|
||||
labels ?? await FileUtil.loadLabels("assets/models/" + labelFileName);
|
||||
_logger.info("Labels initialized");
|
||||
_labels = labels ?? await FileUtil.loadLabels(labelPath);
|
||||
logger.info("Labels initialized");
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error while loading labels", e, s);
|
||||
logger.severe("Error while loading labels", e, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-process the image
|
||||
TensorImage _getProcessedImage(TensorImage inputImage) {
|
||||
padSize = max(inputImage.height, inputImage.width);
|
||||
imageProcessor ??= ImageProcessorBuilder()
|
||||
TensorImage getProcessedImage(TensorImage inputImage) {
|
||||
final padSize = max(inputImage.height, inputImage.width);
|
||||
final imageProcessor = ImageProcessorBuilder()
|
||||
.add(ResizeWithCropOrPadOp(padSize, padSize))
|
||||
.add(ResizeOp(inputSize, inputSize, ResizeMethod.BILINEAR))
|
||||
.build();
|
||||
inputImage = imageProcessor!.process(inputImage);
|
||||
inputImage = imageProcessor.process(inputImage);
|
||||
return inputImage;
|
||||
}
|
||||
|
||||
/// Runs object detection on the input image
|
||||
Predictions? predict(imageLib.Image image) {
|
||||
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Create TensorImage from image
|
||||
TensorImage inputImage = TensorImage.fromImage(image);
|
||||
|
||||
// Pre-process TensorImage
|
||||
inputImage = _getProcessedImage(inputImage);
|
||||
|
||||
final preProcessElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - preProcessStart;
|
||||
|
||||
// TensorBuffers for output tensors
|
||||
final outputLocations = TensorBufferFloat(_outputShapes[0]);
|
||||
final outputClasses = TensorBufferFloat(_outputShapes[1]);
|
||||
final outputScores = TensorBufferFloat(_outputShapes[2]);
|
||||
final numLocations = TensorBufferFloat(_outputShapes[3]);
|
||||
|
||||
// Inputs object for runForMultipleInputs
|
||||
// Use [TensorImage.buffer] or [TensorBuffer.buffer] to pass by reference
|
||||
final inputs = [inputImage.buffer];
|
||||
|
||||
// Outputs map
|
||||
final outputs = {
|
||||
0: outputLocations.buffer,
|
||||
1: outputClasses.buffer,
|
||||
2: outputScores.buffer,
|
||||
3: numLocations.buffer,
|
||||
};
|
||||
|
||||
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// run inference
|
||||
_interpreter.runForMultipleInputs(inputs, outputs);
|
||||
|
||||
final inferenceTimeElapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
|
||||
|
||||
// Maximum number of results to show
|
||||
final resultsCount = min(numResults, numLocations.getIntValue(0));
|
||||
|
||||
// Using labelOffset = 1 as ??? at index 0
|
||||
const labelOffset = 1;
|
||||
|
||||
final recognitions = <Recognition>[];
|
||||
|
||||
for (int i = 0; i < resultsCount; i++) {
|
||||
// Prediction score
|
||||
final score = outputScores.getDoubleValue(i);
|
||||
|
||||
// Label string
|
||||
final labelIndex = outputClasses.getIntValue(i) + labelOffset;
|
||||
final label = _labels.elementAt(labelIndex);
|
||||
|
||||
if (score > threshold) {
|
||||
recognitions.add(
|
||||
Recognition(i, label, score),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final predictElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - predictStartTime;
|
||||
_logger.info(recognitions);
|
||||
return Predictions(
|
||||
recognitions,
|
||||
Stats(
|
||||
predictElapsedTime,
|
||||
predictElapsedTime,
|
||||
inferenceTimeElapsed,
|
||||
preProcessElapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the interpreter instance
|
||||
Interpreter get interpreter => _interpreter;
|
||||
|
||||
/// Gets the loaded labels
|
||||
List<String> get labels => _labels;
|
||||
}
|
||||
|
|
115
lib/services/object_detection/tflite/cocossd_classifier.dart
Normal file
115
lib/services/object_detection/tflite/cocossd_classifier.dart
Normal file
|
@ -0,0 +1,115 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:image/image.dart' as image_lib;
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/services/object_detection/models/predictions.dart';
|
||||
import 'package:photos/services/object_detection/models/recognition.dart';
|
||||
import "package:photos/services/object_detection/models/stats.dart";
|
||||
import "package:photos/services/object_detection/tflite/classifier.dart";
|
||||
import "package:tflite_flutter/tflite_flutter.dart";
|
||||
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
|
||||
|
||||
/// Classifier
|
||||
class CocoSSDClassifier extends Classifier {
|
||||
static final _logger = Logger("CocoSSDClassifier");
|
||||
static const double threshold = 0.5;
|
||||
|
||||
@override
|
||||
String get modelPath => "models/cocossd/model.tflite";
|
||||
|
||||
@override
|
||||
String get labelPath => "assets/models/cocossd/labels.txt";
|
||||
|
||||
@override
|
||||
int get inputSize => 300;
|
||||
|
||||
@override
|
||||
Logger get logger => _logger;
|
||||
|
||||
static const int numResults = 10;
|
||||
|
||||
CocoSSDClassifier({
|
||||
Interpreter? interpreter,
|
||||
List<String>? labels,
|
||||
}) {
|
||||
loadModel(interpreter);
|
||||
loadLabels(labels);
|
||||
}
|
||||
|
||||
@override
|
||||
Predictions? predict(image_lib.Image image) {
|
||||
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Create TensorImage from image
|
||||
TensorImage inputImage = TensorImage.fromImage(image);
|
||||
|
||||
// Pre-process TensorImage
|
||||
inputImage = getProcessedImage(inputImage);
|
||||
|
||||
final preProcessElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - preProcessStart;
|
||||
|
||||
// TensorBuffers for output tensors
|
||||
final outputLocations = TensorBufferFloat(outputShapes[0]);
|
||||
final outputClasses = TensorBufferFloat(outputShapes[1]);
|
||||
final outputScores = TensorBufferFloat(outputShapes[2]);
|
||||
final numLocations = TensorBufferFloat(outputShapes[3]);
|
||||
|
||||
// Inputs object for runForMultipleInputs
|
||||
// Use [TensorImage.buffer] or [TensorBuffer.buffer] to pass by reference
|
||||
final inputs = [inputImage.buffer];
|
||||
|
||||
// Outputs map
|
||||
final outputs = {
|
||||
0: outputLocations.buffer,
|
||||
1: outputClasses.buffer,
|
||||
2: outputScores.buffer,
|
||||
3: numLocations.buffer,
|
||||
};
|
||||
|
||||
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// run inference
|
||||
interpreter.runForMultipleInputs(inputs, outputs);
|
||||
|
||||
final inferenceTimeElapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
|
||||
|
||||
// Maximum number of results to show
|
||||
final resultsCount = min(numResults, numLocations.getIntValue(0));
|
||||
|
||||
// Using labelOffset = 1 as ??? at index 0
|
||||
const labelOffset = 1;
|
||||
|
||||
final recognitions = <Recognition>[];
|
||||
|
||||
for (int i = 0; i < resultsCount; i++) {
|
||||
// Prediction score
|
||||
final score = outputScores.getDoubleValue(i);
|
||||
|
||||
// Label string
|
||||
final labelIndex = outputClasses.getIntValue(i) + labelOffset;
|
||||
final label = labels.elementAt(labelIndex);
|
||||
|
||||
if (score > threshold) {
|
||||
recognitions.add(
|
||||
Recognition(i, label, score),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final predictElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - predictStartTime;
|
||||
return Predictions(
|
||||
recognitions,
|
||||
Stats(
|
||||
predictElapsedTime,
|
||||
predictElapsedTime,
|
||||
inferenceTimeElapsed,
|
||||
preProcessElapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:image/image.dart' as image_lib;
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/services/object_detection/models/predictions.dart';
|
||||
import 'package:photos/services/object_detection/models/recognition.dart';
|
||||
import "package:photos/services/object_detection/models/stats.dart";
|
||||
import "package:photos/services/object_detection/tflite/classifier.dart";
|
||||
import "package:tflite_flutter/tflite_flutter.dart";
|
||||
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
|
||||
|
||||
// Source: https://tfhub.dev/tensorflow/lite-model/mobilenet_v1_1.0_224/1/default/1
|
||||
class MobileNetClassifier extends Classifier {
|
||||
static final _logger = Logger("MobileNetClassifier");
|
||||
static const double threshold = 0.5;
|
||||
|
||||
@override
|
||||
String get modelPath => "models/mobilenet/mobilenet_v1_1.0_224_quant.tflite";
|
||||
|
||||
@override
|
||||
String get labelPath =>
|
||||
"assets/models/mobilenet/labels_mobilenet_quant_v1_224.txt";
|
||||
|
||||
@override
|
||||
int get inputSize => 224;
|
||||
|
||||
@override
|
||||
Logger get logger => _logger;
|
||||
|
||||
MobileNetClassifier({
|
||||
Interpreter? interpreter,
|
||||
List<String>? labels,
|
||||
}) {
|
||||
loadModel(interpreter);
|
||||
loadLabels(labels);
|
||||
}
|
||||
|
||||
@override
|
||||
Predictions? predict(image_lib.Image image) {
|
||||
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Create TensorImage from image
|
||||
TensorImage inputImage = TensorImage.fromImage(image);
|
||||
|
||||
// Pre-process TensorImage
|
||||
inputImage = getProcessedImage(inputImage);
|
||||
|
||||
final preProcessElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - preProcessStart;
|
||||
|
||||
// TensorBuffers for output tensors
|
||||
final output = TensorBufferUint8(outputShapes[0]);
|
||||
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
|
||||
// run inference
|
||||
interpreter.run(inputImage.buffer, output.buffer);
|
||||
|
||||
final inferenceTimeElapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
|
||||
|
||||
final recognitions = <Recognition>[];
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
final score = output.getDoubleValue(i) / 255;
|
||||
if (score >= threshold) {
|
||||
final label = labels.elementAt(i);
|
||||
|
||||
recognitions.add(
|
||||
Recognition(i, label, score),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final predictElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - predictStartTime;
|
||||
return Predictions(
|
||||
recognitions,
|
||||
Stats(
|
||||
predictElapsedTime,
|
||||
predictElapsedTime,
|
||||
inferenceTimeElapsed,
|
||||
preProcessElapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
82
lib/services/object_detection/tflite/scene_classifier.dart
Normal file
82
lib/services/object_detection/tflite/scene_classifier.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'package:image/image.dart' as image_lib;
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/services/object_detection/models/predictions.dart';
|
||||
import 'package:photos/services/object_detection/models/recognition.dart';
|
||||
import "package:photos/services/object_detection/models/stats.dart";
|
||||
import "package:photos/services/object_detection/tflite/classifier.dart";
|
||||
import "package:tflite_flutter/tflite_flutter.dart";
|
||||
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
|
||||
|
||||
// Source: https://tfhub.dev/sayannath/lite-model/image-scene/1
|
||||
class SceneClassifier extends Classifier {
|
||||
static final _logger = Logger("SceneClassifier");
|
||||
static const double threshold = 0.5;
|
||||
|
||||
@override
|
||||
String get modelPath => "models/scenes/model.tflite";
|
||||
|
||||
@override
|
||||
String get labelPath => "assets/models/scenes/labels.txt";
|
||||
|
||||
@override
|
||||
int get inputSize => 224;
|
||||
|
||||
@override
|
||||
Logger get logger => _logger;
|
||||
|
||||
SceneClassifier({
|
||||
Interpreter? interpreter,
|
||||
List<String>? labels,
|
||||
}) {
|
||||
loadModel(interpreter);
|
||||
loadLabels(labels);
|
||||
}
|
||||
|
||||
@override
|
||||
Predictions? predict(image_lib.Image image) {
|
||||
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Create TensorImage from image
|
||||
TensorImage inputImage = TensorImage.fromImage(image);
|
||||
|
||||
// Pre-process TensorImage
|
||||
inputImage = getProcessedImage(inputImage);
|
||||
final list = inputImage.getTensorBuffer().getDoubleList();
|
||||
final input = list.reshape([1, inputSize, inputSize, 3]);
|
||||
|
||||
final preProcessElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - preProcessStart;
|
||||
|
||||
final output = TensorBufferFloat(outputShapes[0]);
|
||||
|
||||
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
|
||||
interpreter.run(input, output.buffer);
|
||||
final inferenceTimeElapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
|
||||
|
||||
final recognitions = <Recognition>[];
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
final score = output.getDoubleValue(i);
|
||||
final label = labels.elementAt(i);
|
||||
if (score >= threshold) {
|
||||
recognitions.add(
|
||||
Recognition(i, label, score),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final predictElapsedTime =
|
||||
DateTime.now().millisecondsSinceEpoch - predictStartTime;
|
||||
return Predictions(
|
||||
recognitions,
|
||||
Stats(
|
||||
predictElapsedTime,
|
||||
predictElapsedTime,
|
||||
inferenceTimeElapsed,
|
||||
preProcessElapsedTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,9 @@ import "dart:typed_data";
|
|||
|
||||
import 'package:image/image.dart' as imgLib;
|
||||
import "package:photos/services/object_detection/tflite/classifier.dart";
|
||||
import 'package:photos/services/object_detection/tflite/cocossd_classifier.dart';
|
||||
import "package:photos/services/object_detection/tflite/mobilenet_classifier.dart";
|
||||
import "package:photos/services/object_detection/tflite/scene_classifier.dart";
|
||||
import 'package:tflite_flutter/tflite_flutter.dart';
|
||||
|
||||
/// Manages separate Isolate instance for inference
|
||||
|
@ -29,15 +32,32 @@ class IsolateUtils {
|
|||
sendPort.send(port.sendPort);
|
||||
|
||||
await for (final IsolateData isolateData in port) {
|
||||
final classifier = ObjectClassifier(
|
||||
interpreter: Interpreter.fromAddress(isolateData.interpreterAddress),
|
||||
labels: isolateData.labels,
|
||||
);
|
||||
final classifier = _getClassifier(isolateData);
|
||||
final image = imgLib.decodeImage(isolateData.input);
|
||||
final results = classifier.predict(image!);
|
||||
isolateData.responsePort.send(results);
|
||||
}
|
||||
}
|
||||
|
||||
static Classifier _getClassifier(IsolateData isolateData) {
|
||||
final interpreter = Interpreter.fromAddress(isolateData.interpreterAddress);
|
||||
if (isolateData.type == ClassifierType.cocossd) {
|
||||
return CocoSSDClassifier(
|
||||
interpreter: interpreter,
|
||||
labels: isolateData.labels,
|
||||
);
|
||||
} else if (isolateData.type == ClassifierType.mobilenet) {
|
||||
return MobileNetClassifier(
|
||||
interpreter: interpreter,
|
||||
labels: isolateData.labels,
|
||||
);
|
||||
} else {
|
||||
return SceneClassifier(
|
||||
interpreter: interpreter,
|
||||
labels: isolateData.labels,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles data to pass between Isolate
|
||||
|
@ -45,11 +65,19 @@ class IsolateData {
|
|||
Uint8List input;
|
||||
int interpreterAddress;
|
||||
List<String> labels;
|
||||
ClassifierType type;
|
||||
late SendPort responsePort;
|
||||
|
||||
IsolateData(
|
||||
this.input,
|
||||
this.interpreterAddress,
|
||||
this.labels,
|
||||
this.type,
|
||||
);
|
||||
}
|
||||
|
||||
enum ClassifierType {
|
||||
cocossd,
|
||||
mobilenet,
|
||||
scenes,
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import 'package:photos/models/file_type.dart';
|
|||
import 'package:photos/models/upload_strategy.dart';
|
||||
import 'package:photos/services/app_lifecycle_service.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/feature_flag_service.dart";
|
||||
import 'package:photos/services/ignored_files_service.dart';
|
||||
import 'package:photos/services/local_file_update_service.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
|
@ -44,7 +45,7 @@ class RemoteSyncService {
|
|||
int _completedUploads = 0;
|
||||
late SharedPreferences _prefs;
|
||||
Completer<void>? _existingSync;
|
||||
bool _existingSyncSilent = false;
|
||||
bool _isExistingSyncSilent = false;
|
||||
|
||||
static const kHasSyncedArchiveKey = "has_synced_archive";
|
||||
final String _isFirstRemoteSyncDone = "isFirstRemoteSyncDone";
|
||||
|
@ -84,13 +85,17 @@ class RemoteSyncService {
|
|||
_logger.info("Remote sync already in progress, skipping");
|
||||
// if current sync is silent but request sync is non-silent (demands UI
|
||||
// updates), update the syncSilently flag
|
||||
if (_existingSyncSilent == true && silently == false) {
|
||||
_existingSyncSilent = false;
|
||||
if (_isExistingSyncSilent && !silently) {
|
||||
_isExistingSyncSilent = false;
|
||||
}
|
||||
return _existingSync?.future;
|
||||
}
|
||||
_existingSync = Completer<void>();
|
||||
_existingSyncSilent = silently;
|
||||
_isExistingSyncSilent = silently;
|
||||
_logger.info(
|
||||
"Starting remote sync " +
|
||||
(silently ? "silently" : " with status updates"),
|
||||
);
|
||||
|
||||
try {
|
||||
// use flag to decide if we should start marking files for upload before
|
||||
|
@ -115,18 +120,20 @@ class RemoteSyncService {
|
|||
}
|
||||
final filesToBeUploaded = await _getFilesToBeUploaded();
|
||||
final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
|
||||
_logger.info("File upload complete");
|
||||
if (hasUploadedFiles) {
|
||||
await _pullDiff();
|
||||
_existingSync?.complete();
|
||||
_existingSync = null;
|
||||
await syncDeviceCollectionFilesForUpload();
|
||||
final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
|
||||
_logger.info("hasMoreFilesToBackup?" + hasMoreFilesToBackup.toString());
|
||||
if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
|
||||
// Skipping a resync to ensure that files that were ignored in this
|
||||
// session are not processed now
|
||||
sync();
|
||||
} else {
|
||||
debugPrint("Fire backup completed event");
|
||||
_logger.info("Fire backup completed event");
|
||||
Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
|
||||
}
|
||||
} else {
|
||||
|
@ -146,13 +153,17 @@ class RemoteSyncService {
|
|||
rethrow;
|
||||
} else {
|
||||
_logger.severe("Error executing remote sync ", e, s);
|
||||
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_existingSyncSilent = false;
|
||||
_isExistingSyncSilent = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullDiff() async {
|
||||
_logger.info("Pulling remote diff");
|
||||
final isFirstSync = !_collectionsService.hasSyncedCollections();
|
||||
await _collectionsService.sync();
|
||||
// check and reset user's collection syncTime in past for older clients
|
||||
|
@ -179,6 +190,7 @@ class RemoteSyncService {
|
|||
);
|
||||
await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
|
||||
}
|
||||
_logger.info("All updated collections synced");
|
||||
}
|
||||
|
||||
Future<void> _resetAllCollectionsSyncTime() async {
|
||||
|
@ -193,7 +205,12 @@ class RemoteSyncService {
|
|||
}
|
||||
|
||||
Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
|
||||
if (!_existingSyncSilent) {
|
||||
_logger.info(
|
||||
"Syncing collection #" +
|
||||
collectionID.toString() +
|
||||
(_isExistingSyncSilent ? " silently" : ""),
|
||||
);
|
||||
if (!_isExistingSyncSilent) {
|
||||
Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
|
||||
}
|
||||
final diff =
|
||||
|
@ -252,10 +269,13 @@ class RemoteSyncService {
|
|||
collectionID,
|
||||
_collectionsService.getCollectionSyncTime(collectionID),
|
||||
);
|
||||
} else {
|
||||
_logger.info("Collection #" + collectionID.toString() + " synced");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncDeviceCollectionFilesForUpload() async {
|
||||
_logger.info("Syncing device collections to be uploaded");
|
||||
final int ownerID = _config.getUserID()!;
|
||||
|
||||
final deviceCollections = await _db.getDeviceCollections();
|
||||
|
@ -279,15 +299,15 @@ class RemoteSyncService {
|
|||
if (localIDsToSync.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
await _createCollectionForDevicePath(deviceCollection);
|
||||
if (deviceCollection.collectionID == -1) {
|
||||
_logger.finest('DeviceCollection should not be -1 here');
|
||||
final collectionID = await _getCollectionID(deviceCollection);
|
||||
if (collectionID == null) {
|
||||
_logger.warning('DeviceCollection was either deleted or missing');
|
||||
continue;
|
||||
}
|
||||
|
||||
moreFilesMarkedForBackup = true;
|
||||
await _db.setCollectionIDForUnMappedLocalFiles(
|
||||
deviceCollection.collectionID!,
|
||||
collectionID,
|
||||
localIDsToSync,
|
||||
);
|
||||
|
||||
|
@ -295,15 +315,13 @@ class RemoteSyncService {
|
|||
// the collection. This can happen when a user has marked a folder
|
||||
// for sync, then un-synced it and again tries to mark if for sync.
|
||||
final Set<String> existingMapping =
|
||||
await _db.getLocalFileIDsForCollection(
|
||||
deviceCollection.collectionID!,
|
||||
);
|
||||
await _db.getLocalFileIDsForCollection(collectionID);
|
||||
final Set<String> commonElements =
|
||||
localIDsToSync.intersection(existingMapping);
|
||||
if (commonElements.isNotEmpty) {
|
||||
debugPrint(
|
||||
"${commonElements.length} files already existing in "
|
||||
"collection ${deviceCollection.collectionID} for ${deviceCollection.name}",
|
||||
"collection $collectionID for ${deviceCollection.name}",
|
||||
);
|
||||
localIDsToSync.removeAll(commonElements);
|
||||
}
|
||||
|
@ -324,7 +342,7 @@ class RemoteSyncService {
|
|||
final String localID = existingFile.localID!;
|
||||
if (!fileFoundForLocalIDs.contains(localID)) {
|
||||
existingFile.generatedID = null;
|
||||
existingFile.collectionID = deviceCollection.collectionID;
|
||||
existingFile.collectionID = collectionID;
|
||||
existingFile.uploadedFileID = null;
|
||||
existingFile.ownerID = null;
|
||||
newFilesToInsert.add(existingFile);
|
||||
|
@ -414,27 +432,37 @@ class RemoteSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _createCollectionForDevicePath(
|
||||
DeviceCollection deviceCollection,
|
||||
) async {
|
||||
int deviceCollectionID = deviceCollection.collectionID ?? -1;
|
||||
if (deviceCollectionID != -1) {
|
||||
final collectionByID =
|
||||
_collectionsService.getCollectionByID(deviceCollectionID);
|
||||
if (collectionByID == null || collectionByID.isDeleted) {
|
||||
_logger.info(
|
||||
"Collection $deviceCollectionID either deleted or missing "
|
||||
"for path ${deviceCollection.id}",
|
||||
Future<int?> _getCollectionID(DeviceCollection deviceCollection) async {
|
||||
if (deviceCollection.hasCollectionID()) {
|
||||
final collection =
|
||||
_collectionsService.getCollectionByID(deviceCollection.collectionID!);
|
||||
if (collection != null && !collection.isDeleted) {
|
||||
return collection.id;
|
||||
}
|
||||
if (collection == null) {
|
||||
// ideally, this should never happen because the app keeps a track of
|
||||
// all collections and their IDs. But, if somehow the collection is
|
||||
// deleted, we should fetch it again
|
||||
_logger.severe(
|
||||
"Collection ${deviceCollection.collectionID} missing "
|
||||
"for pathID ${deviceCollection.id}",
|
||||
);
|
||||
deviceCollectionID = -1;
|
||||
_collectionsService
|
||||
.fetchCollectionByID(deviceCollection.collectionID!)
|
||||
.ignore();
|
||||
// return, by next run collection should be available.
|
||||
// we are not waiting on fetch by choice because device might have wrong
|
||||
// mapping which will result in breaking upload for other device path
|
||||
return null;
|
||||
} else if (collection.isDeleted) {
|
||||
_logger.warning("Collection ${deviceCollection.collectionID} deleted "
|
||||
"for pathID ${deviceCollection.id}, new collection will be created");
|
||||
}
|
||||
}
|
||||
if (deviceCollectionID == -1) {
|
||||
final collection =
|
||||
await _collectionsService.getOrCreateForPath(deviceCollection.name);
|
||||
await _db.updateDeviceCollection(deviceCollection.id, collection.id);
|
||||
deviceCollection.collectionID = collection.id;
|
||||
}
|
||||
final collection =
|
||||
await _collectionsService.getOrCreateForPath(deviceCollection.name);
|
||||
await _db.updateDeviceCollection(deviceCollection.id, collection.id);
|
||||
return collection.id;
|
||||
}
|
||||
|
||||
Future<List<File>> _getFilesToBeUploaded() async {
|
||||
|
|
|
@ -2,7 +2,6 @@ import "dart:convert";
|
|||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/core/network/network.dart';
|
||||
import 'package:photos/data/holidays.dart';
|
||||
import 'package:photos/data/months.dart';
|
||||
import 'package:photos/data/years.dart';
|
||||
|
@ -24,7 +23,6 @@ import 'package:tuple/tuple.dart';
|
|||
|
||||
class SearchService {
|
||||
Future<List<File>>? _cachedFilesFuture;
|
||||
final _enteDio = NetworkClient.instance.enteDio;
|
||||
final _logger = Logger((SearchService).toString());
|
||||
final _collectionService = CollectionsService.instance;
|
||||
static const _maximumResultsLimit = 20;
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip39/bip39.dart' as bip39;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/core/network/network.dart';
|
||||
import 'package:photos/db/public_keys_db.dart';
|
||||
|
@ -23,7 +25,9 @@ import 'package:photos/ui/account/ott_verification_page.dart';
|
|||
import 'package:photos/ui/account/password_entry_page.dart';
|
||||
import 'package:photos/ui/account/password_reentry_page.dart';
|
||||
import 'package:photos/ui/account/two_factor_authentication_page.dart';
|
||||
import 'package:photos/ui/account/two_factor_recovery_page.dart';
|
||||
import 'package:photos/ui/account/two_factor_setup_page.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
@ -112,6 +116,17 @@ class UserService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> sendFeedback(
|
||||
BuildContext context,
|
||||
String feedback, {
|
||||
String type = "SubCancellation",
|
||||
}) async {
|
||||
await _dio.post(
|
||||
_config.getHttpEndpoint() + "/anonymous/feedback",
|
||||
data: {"feedback": feedback, "type": "type"},
|
||||
);
|
||||
}
|
||||
|
||||
// getPublicKey returns null value if email id is not
|
||||
// associated with another ente account
|
||||
Future<String?> getPublicKey(String email) async {
|
||||
|
@ -153,6 +168,10 @@ class UserService {
|
|||
final userDetails = UserDetails.fromMap(response.data);
|
||||
if (shouldCache) {
|
||||
await _preferences.setString(keyUserDetails, userDetails.toJson());
|
||||
// handle email change from different client
|
||||
if (userDetails.email != _config.getEmail()) {
|
||||
setEmail(userDetails.email);
|
||||
}
|
||||
}
|
||||
return userDetails;
|
||||
} on DioError catch (e) {
|
||||
|
@ -218,13 +237,9 @@ class UserService {
|
|||
Future<DeleteChallengeResponse?> getDeleteChallenge(
|
||||
BuildContext context,
|
||||
) async {
|
||||
final dialog = createProgressDialog(context, "Please wait...");
|
||||
await dialog.show();
|
||||
try {
|
||||
final response = await _enteDio.get("/users/delete-challenge");
|
||||
if (response.statusCode == 200) {
|
||||
// clear data
|
||||
await dialog.hide();
|
||||
return DeleteChallengeResponse(
|
||||
allowDelete: response.data["allowDelete"] as bool,
|
||||
encryptedChallenge: response.data["encryptedChallenge"],
|
||||
|
@ -234,7 +249,6 @@ class UserService {
|
|||
}
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
await dialog.hide();
|
||||
await showGenericErrorDialog(context: context);
|
||||
return null;
|
||||
}
|
||||
|
@ -242,13 +256,17 @@ class UserService {
|
|||
|
||||
Future<void> deleteAccount(
|
||||
BuildContext context,
|
||||
String challengeResponse,
|
||||
) async {
|
||||
String challengeResponse, {
|
||||
required String reasonCategory,
|
||||
required String feedback,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enteDio.delete(
|
||||
"/users/delete",
|
||||
data: {
|
||||
"challenge": challengeResponse,
|
||||
"reasonCategory": reasonCategory,
|
||||
"feedback": feedback,
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
|
@ -489,6 +507,147 @@ class UserService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
|
||||
final dialog = createProgressDialog(context, "Please wait...");
|
||||
await dialog.show();
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
_config.getHttpEndpoint() + "/users/two-factor/recover",
|
||||
queryParameters: {
|
||||
"sessionID": sessionID,
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return TwoFactorRecoveryPage(
|
||||
sessionID,
|
||||
response.data["encryptedSecret"],
|
||||
response.data["secretDecryptionNonce"],
|
||||
);
|
||||
},
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
_logger.severe(e);
|
||||
if (e.response != null && e.response!.statusCode == 404) {
|
||||
showToast(context, "Session expired");
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const LoginPage();
|
||||
},
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
} else {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Oops",
|
||||
"Something went wrong, please try again",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Oops",
|
||||
"Something went wrong, please try again",
|
||||
);
|
||||
} finally {
|
||||
await dialog.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeTwoFactor(
|
||||
BuildContext context,
|
||||
String sessionID,
|
||||
String recoveryKey,
|
||||
String encryptedSecret,
|
||||
String secretDecryptionNonce,
|
||||
) async {
|
||||
final dialog = createProgressDialog(context, "Please wait...");
|
||||
await dialog.show();
|
||||
String secret;
|
||||
try {
|
||||
if (recoveryKey.contains(' ')) {
|
||||
if (recoveryKey.split(' ').length != mnemonicKeyWordCount) {
|
||||
throw AssertionError(
|
||||
'recovery code should have $mnemonicKeyWordCount words',
|
||||
);
|
||||
}
|
||||
recoveryKey = bip39.mnemonicToEntropy(recoveryKey);
|
||||
}
|
||||
secret = CryptoUtil.bin2base64(
|
||||
await CryptoUtil.decrypt(
|
||||
CryptoUtil.base642bin(encryptedSecret),
|
||||
CryptoUtil.hex2bin(recoveryKey.trim()),
|
||||
CryptoUtil.base642bin(secretDecryptionNonce),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
await dialog.hide();
|
||||
await showErrorDialog(
|
||||
context,
|
||||
"Incorrect recovery key",
|
||||
"The recovery key you entered is incorrect",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
_config.getHttpEndpoint() + "/users/two-factor/remove",
|
||||
data: {
|
||||
"sessionID": sessionID,
|
||||
"secret": secret,
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
showShortToast(context, "Two-factor authentication successfully reset");
|
||||
await _saveConfiguration(response);
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const PasswordReentryPage();
|
||||
},
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
} on DioError catch (e) {
|
||||
_logger.severe(e);
|
||||
if (e.response != null && e.response!.statusCode == 404) {
|
||||
showToast(context, "Session expired");
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const LoginPage();
|
||||
},
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
} else {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Oops",
|
||||
"Something went wrong, please try again",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Oops",
|
||||
"Something went wrong, please try again",
|
||||
);
|
||||
} finally {
|
||||
await dialog.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setupTwoFactor(BuildContext context, Completer completer) async {
|
||||
final dialog = createProgressDialog(context, "Please wait...");
|
||||
await dialog.show();
|
||||
|
@ -518,13 +677,26 @@ class UserService {
|
|||
String secret,
|
||||
String code,
|
||||
) async {
|
||||
Uint8List recoveryKey;
|
||||
try {
|
||||
recoveryKey = await getOrCreateRecoveryKey(context);
|
||||
} catch (e) {
|
||||
showGenericErrorDialog(context: context);
|
||||
return false;
|
||||
}
|
||||
final dialog = createProgressDialog(context, "Verifying...");
|
||||
await dialog.show();
|
||||
final encryptionResult =
|
||||
CryptoUtil.encryptSync(CryptoUtil.base642bin(secret), recoveryKey);
|
||||
try {
|
||||
await _enteDio.post(
|
||||
"/users/two-factor/enable",
|
||||
data: {
|
||||
"code": code
|
||||
"code": code,
|
||||
"encryptedTwoFactorSecret":
|
||||
CryptoUtil.bin2base64(encryptionResult.encryptedData!),
|
||||
"twoFactorSecretDecryptionNonce":
|
||||
CryptoUtil.bin2base64(encryptionResult.nonce!),
|
||||
},
|
||||
);
|
||||
await dialog.hide();
|
||||
|
|
|
@ -1,22 +1,43 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import "package:dropdown_button2/dropdown_button2.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/models/delete_account.dart';
|
||||
import 'package:photos/services/local_authentication_service.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/email_util.dart';
|
||||
import "package:photos/utils/toast_util.dart";
|
||||
|
||||
class DeleteAccountPage extends StatelessWidget {
|
||||
class DeleteAccountPage extends StatefulWidget {
|
||||
const DeleteAccountPage({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DeleteAccountPage> createState() => _DeleteAccountPageState();
|
||||
}
|
||||
|
||||
class _DeleteAccountPageState extends State<DeleteAccountPage> {
|
||||
bool _hasConfirmedDeletion = false;
|
||||
final _feedbackTextCtrl = TextEditingController();
|
||||
final String _defaultSelection = 'Select reason';
|
||||
late String _dropdownValue = _defaultSelection;
|
||||
late final List<String> _deletionReason = [
|
||||
_defaultSelection,
|
||||
'It’s missing a key feature that I need',
|
||||
'The app or a certain feature does not \nbehave as I think it should',
|
||||
'I found another service that I like better',
|
||||
'I use a different account',
|
||||
'My reason isn’t listed',
|
||||
];
|
||||
final List<int> _reasonIndexesWhereFeedbackIsNecessary = [1, 2, 5];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
@ -37,68 +58,150 @@ class DeleteAccountPage extends StatelessWidget {
|
|||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/broken_heart.png',
|
||||
width: 200,
|
||||
Text(
|
||||
"What is the main reason you are deleting your account?",
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
"We'll be sorry to see you go. Are you facing some issue?",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.subtitle1!
|
||||
.copyWith(color: colorScheme.textMuted),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButton2<String>(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
value: _dropdownValue,
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_dropdownValue = newValue!;
|
||||
});
|
||||
},
|
||||
underline: const SizedBox(),
|
||||
items: _deletionReason
|
||||
.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
enabled: value != _defaultSelection,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
style: value != _defaultSelection
|
||||
? getEnteTextTheme(context).small
|
||||
: getEnteTextTheme(context).smallMuted,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"We are sorry to see you go. Please share your feedback to "
|
||||
"help us improve.",
|
||||
style: getEnteTextTheme(context).body,
|
||||
),
|
||||
RichText(
|
||||
// textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
children: const [
|
||||
TextSpan(text: "Please write to us at "),
|
||||
TextSpan(
|
||||
text: "feedback@ente.io",
|
||||
style: TextStyle(color: Color.fromRGBO(29, 185, 84, 1)),
|
||||
),
|
||||
TextSpan(
|
||||
text: ", maybe there is a way we can help.",
|
||||
),
|
||||
],
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.subtitle1!
|
||||
.copyWith(color: colorScheme.textMuted),
|
||||
const SizedBox(height: 4),
|
||||
TextFormField(
|
||||
decoration: InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide:
|
||||
BorderSide(color: colorScheme.strokeFaint, width: 1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide:
|
||||
BorderSide(color: colorScheme.strokeFaint, width: 1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.transparent,
|
||||
hintText: "Feedback",
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: "Yes, send feedback",
|
||||
icon: Icons.check_outlined,
|
||||
onTap: () async {
|
||||
await sendEmail(
|
||||
context,
|
||||
to: 'feedback@ente.io',
|
||||
subject: '[Feedback]',
|
||||
);
|
||||
controller: _feedbackTextCtrl,
|
||||
autofocus: false,
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.tertiaryCritical,
|
||||
labelText: "No, delete account",
|
||||
icon: Icons.no_accounts_outlined,
|
||||
onTap: () async => {await _initiateDelete(context)},
|
||||
shouldSurfaceExecutionStates: false,
|
||||
)
|
||||
_shouldAskForFeedback()
|
||||
? SizedBox(
|
||||
height: 42,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
"Kindly help us with this information",
|
||||
style: getEnteTextTheme(context)
|
||||
.smallBold
|
||||
.copyWith(color: colorScheme.warning700),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(height: 42),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_hasConfirmedDeletion = !_hasConfirmedDeletion;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _hasConfirmedDeletion,
|
||||
side: CheckboxTheme.of(context).side,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_hasConfirmedDeletion = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Yes, I want to permanently delete this account and "
|
||||
"all its data.",
|
||||
style: getEnteTextTheme(context).bodyMuted,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.critical,
|
||||
labelText: "Confirm Account Deletion",
|
||||
isDisabled: _shouldBlockDeletion(),
|
||||
onTap: () async {
|
||||
await _initiateDelete(context);
|
||||
},
|
||||
shouldSurfaceExecutionStates: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.secondary,
|
||||
labelText: "Cancel",
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SafeArea(
|
||||
child: SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -106,58 +209,69 @@ class DeleteAccountPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
bool _shouldBlockDeletion() {
|
||||
return !_hasConfirmedDeletion ||
|
||||
_dropdownValue == _defaultSelection ||
|
||||
_shouldAskForFeedback();
|
||||
}
|
||||
|
||||
bool _shouldAskForFeedback() {
|
||||
return (_reasonIndexesWhereFeedbackIsNecessary
|
||||
.contains(_deletionReason.indexOf(_dropdownValue)) &&
|
||||
_feedbackTextCtrl.text.trim().isEmpty);
|
||||
}
|
||||
|
||||
Future<void> _initiateDelete(BuildContext context) async {
|
||||
final deleteChallengeResponse =
|
||||
await UserService.instance.getDeleteChallenge(context);
|
||||
if (deleteChallengeResponse == null) {
|
||||
return;
|
||||
}
|
||||
if (deleteChallengeResponse.allowDelete) {
|
||||
await _confirmAndDelete(context, deleteChallengeResponse);
|
||||
} else {
|
||||
await _requestEmailForDeletion(context);
|
||||
final choice = await showChoiceDialog(
|
||||
context,
|
||||
title: "Confirm Account Deletion",
|
||||
body: "You are about to permanently delete your account and all its data."
|
||||
"\nThis action is irreversible.",
|
||||
firstButtonLabel: "Delete Account Permanently",
|
||||
firstButtonType: ButtonType.critical,
|
||||
firstButtonOnTap: () async {
|
||||
final deleteChallengeResponse =
|
||||
await UserService.instance.getDeleteChallenge(context);
|
||||
if (deleteChallengeResponse == null) {
|
||||
return;
|
||||
}
|
||||
if (deleteChallengeResponse.allowDelete) {
|
||||
await _delete(context, deleteChallengeResponse);
|
||||
} else {
|
||||
await _requestEmailForDeletion(context);
|
||||
}
|
||||
},
|
||||
isDismissible: false,
|
||||
);
|
||||
if (choice!.action == ButtonAction.error) {
|
||||
await showGenericErrorDialog(context: context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmAndDelete(
|
||||
Future<void> _delete(
|
||||
BuildContext context,
|
||||
DeleteChallengeResponse response,
|
||||
) async {
|
||||
final hasAuthenticated =
|
||||
await LocalAuthenticationService.instance.requestLocalAuthentication(
|
||||
context,
|
||||
"Please authenticate to initiate account deletion",
|
||||
);
|
||||
|
||||
if (hasAuthenticated) {
|
||||
final choice = await showChoiceDialog(
|
||||
context,
|
||||
title: 'Are you sure you want to delete your account?',
|
||||
body:
|
||||
'Your uploaded data will be scheduled for deletion, and your account'
|
||||
' will be permanently deleted. \n\nThis action is not reversible.',
|
||||
firstButtonLabel: "Delete my account",
|
||||
isCritical: true,
|
||||
firstButtonOnTap: () async {
|
||||
final decryptChallenge = CryptoUtil.openSealSync(
|
||||
CryptoUtil.base642bin(response.encryptedChallenge),
|
||||
CryptoUtil.base642bin(
|
||||
Configuration.instance.getKeyAttributes()!.publicKey,
|
||||
),
|
||||
Configuration.instance.getSecretKey()!,
|
||||
);
|
||||
final challengeResponseStr = utf8.decode(decryptChallenge);
|
||||
await UserService.instance
|
||||
.deleteAccount(context, challengeResponseStr);
|
||||
},
|
||||
try {
|
||||
final decryptChallenge = CryptoUtil.openSealSync(
|
||||
CryptoUtil.base642bin(response.encryptedChallenge),
|
||||
CryptoUtil.base642bin(
|
||||
Configuration.instance.getKeyAttributes()!.publicKey,
|
||||
),
|
||||
Configuration.instance.getSecretKey()!,
|
||||
);
|
||||
final challengeResponseStr = utf8.decode(decryptChallenge);
|
||||
await UserService.instance.deleteAccount(
|
||||
context,
|
||||
challengeResponseStr,
|
||||
reasonCategory: _dropdownValue,
|
||||
feedback: _feedbackTextCtrl.text.trim(),
|
||||
);
|
||||
if (choice!.action == ButtonAction.error) {
|
||||
showGenericErrorDialog(context: context);
|
||||
}
|
||||
if (choice.action != ButtonAction.first) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
showShortToast(context, "Your account has been deleted");
|
||||
} catch (e, s) {
|
||||
Logger("DeleteAccount").severe("failed to delete", e, s);
|
||||
showGenericErrorDialog(context: context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -258,7 +258,9 @@ class _EmailEntryPageState extends State<EmailEntryPage> {
|
|||
autofillHints: const [AutofillHints.newPassword],
|
||||
onEditingComplete: () => TextInput.finishAutofillContext(),
|
||||
decoration: InputDecoration(
|
||||
fillColor: _passwordsMatch ? _validFieldValueColor : null,
|
||||
fillColor: _passwordsMatch && _passwordIsValid
|
||||
? _validFieldValueColor
|
||||
: null,
|
||||
filled: true,
|
||||
hintText: "Confirm password",
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:photos/core/event_bus.dart';
|
|||
import 'package:photos/events/subscription_purchased_event.dart';
|
||||
import 'package:photos/ui/account/recovery_page.dart';
|
||||
import 'package:photos/ui/common/dynamic_fab.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/home_widget.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/email_util.dart';
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/ui/lifecycle_event_handler.dart';
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import 'package:pinput/pin_put/pin_put.dart';
|
||||
|
||||
class TwoFactorAuthenticationPage extends StatefulWidget {
|
||||
|
@ -124,11 +123,7 @@ class _TwoFactorAuthenticationPageState
|
|||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Contact support",
|
||||
"Please drop an email to support@ente.io from your registered email address",
|
||||
);
|
||||
UserService.instance.recoverTwoFactor(context, widget.sessionID);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
|
|
110
lib/ui/account/two_factor_recovery_page.dart
Normal file
110
lib/ui/account/two_factor_recovery_page.dart
Normal file
|
@ -0,0 +1,110 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
|
||||
class TwoFactorRecoveryPage extends StatefulWidget {
|
||||
final String sessionID;
|
||||
final String encryptedSecret;
|
||||
final String secretDecryptionNonce;
|
||||
|
||||
const TwoFactorRecoveryPage(
|
||||
this.sessionID,
|
||||
this.encryptedSecret,
|
||||
this.secretDecryptionNonce, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TwoFactorRecoveryPage> createState() => _TwoFactorRecoveryPageState();
|
||||
}
|
||||
|
||||
class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
|
||||
final _recoveryKey = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Recover account",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(60, 0, 60, 0),
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Enter your recovery key",
|
||||
contentPadding: EdgeInsets.all(20),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
controller: _recoveryKey,
|
||||
autofocus: false,
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(24)),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(80, 0, 80, 0),
|
||||
width: double.infinity,
|
||||
height: 64,
|
||||
child: OutlinedButton(
|
||||
onPressed: _recoveryKey.text.isNotEmpty
|
||||
? () async {
|
||||
await UserService.instance.removeTwoFactor(
|
||||
context,
|
||||
widget.sessionID,
|
||||
_recoveryKey.text,
|
||||
widget.encryptedSecret,
|
||||
widget.secretDecryptionNonce,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text("Recover"),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Contact support",
|
||||
"Please drop an email to support@ente.io from your registered email address",
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"No recovery key?",
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import 'package:photos/services/user_remote_flag_service.dart';
|
|||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/ui/account/recovery_key_page.dart';
|
||||
import 'package:photos/ui/common/gradient_button.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:photos/services/favorites_service.dart';
|
|||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
import 'package:photos/ui/common/progress_dialog.dart';
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
|
|
@ -15,7 +15,7 @@ import 'package:photos/theme/colors.dart';
|
|||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/common/progress_dialog.dart';
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/dialog_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/payment/subscription.dart';
|
||||
|
|
|
@ -5,9 +5,9 @@ import "package:photos/models/file_type.dart";
|
|||
import "package:photos/theme/colors.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/components/action_sheet_widget.dart";
|
||||
import "package:photos/ui/components/button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/ui/viewer/file/file_info_widget.dart";
|
||||
import 'package:photos/ui/viewer/file/file_details_widget.dart';
|
||||
import "package:photos/utils/delete_file_util.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/toast_util.dart";
|
||||
|
@ -136,7 +136,7 @@ Future<void> showInfoSheet(BuildContext context, File file) async {
|
|||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: FileInfoWidget(file),
|
||||
child: FileDetailsWidget(file),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_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';
|
||||
|
|
|
@ -3,9 +3,9 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.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/menu_section_description_widget.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
|
|
|
@ -14,7 +14,7 @@ import 'package:photos/theme/ente_theme.dart';
|
|||
import "package:photos/ui/collections_list_widget.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/components/bottom_of_title_bar_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import "package:photos/ui/components/text_input_widget.dart";
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
|
@ -153,7 +153,6 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
|||
child: TextInputWidget(
|
||||
hintText: "Album name",
|
||||
prefixIcon: Icons.search_rounded,
|
||||
autoFocus: true,
|
||||
onChange: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
|
@ -199,47 +198,46 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
|||
return Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 4, 0),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
radius: const Radius.circular(2),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: FutureBuilder(
|
||||
future: _getCollectionsWithThumbnail(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
//Need to show an error on the UI here
|
||||
return const SizedBox.shrink();
|
||||
} else if (snapshot.hasData) {
|
||||
final collectionsWithThumbnail =
|
||||
snapshot.data as List<CollectionWithThumbnail>;
|
||||
_removeIncomingCollections(collectionsWithThumbnail);
|
||||
final shouldShowCreateAlbum =
|
||||
widget.showOptionToCreateNewAlbum && _searchQuery.isEmpty;
|
||||
final searchResults = _searchQuery.isNotEmpty
|
||||
? collectionsWithThumbnail
|
||||
.where(
|
||||
(element) => element.collection.name!
|
||||
.toLowerCase()
|
||||
.contains(_searchQuery),
|
||||
)
|
||||
.toList()
|
||||
: collectionsWithThumbnail;
|
||||
return CollectionsListWidget(
|
||||
child: FutureBuilder(
|
||||
future: _getCollectionsWithThumbnail(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
//Need to show an error on the UI here
|
||||
return const SizedBox.shrink();
|
||||
} else if (snapshot.hasData) {
|
||||
final collectionsWithThumbnail =
|
||||
snapshot.data as List<CollectionWithThumbnail>;
|
||||
_removeIncomingCollections(collectionsWithThumbnail);
|
||||
final shouldShowCreateAlbum =
|
||||
widget.showOptionToCreateNewAlbum && _searchQuery.isEmpty;
|
||||
final searchResults = _searchQuery.isNotEmpty
|
||||
? collectionsWithThumbnail
|
||||
.where(
|
||||
(element) => element.collection.name!
|
||||
.toLowerCase()
|
||||
.contains(_searchQuery),
|
||||
)
|
||||
.toList()
|
||||
: collectionsWithThumbnail;
|
||||
return Scrollbar(
|
||||
thumbVisibility: true,
|
||||
radius: const Radius.circular(2),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: CollectionsListWidget(
|
||||
searchResults,
|
||||
widget.actionType,
|
||||
widget.showOptionToCreateNewAlbum,
|
||||
widget.selectedFiles,
|
||||
widget.sharedFiles,
|
||||
_searchQuery,
|
||||
shouldShowCreateAlbum,
|
||||
);
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -21,7 +21,7 @@ import 'package:photos/ui/collections/section_title.dart';
|
|||
import 'package:photos/ui/collections/trash_button_widget.dart';
|
||||
import 'package:photos/ui/collections/uncat_collections_button_widget.dart';
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import "package:photos/ui/components/icon_button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/viewer/actions/delete_empty_albums.dart';
|
||||
import 'package:photos/ui/viewer/gallery/empty_state.dart';
|
||||
import 'package:photos/utils/local_settings.dart';
|
||||
|
|
|
@ -30,7 +30,6 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
|||
class CollectionsListWidget extends StatelessWidget {
|
||||
final List<CollectionWithThumbnail> collectionsWithThumbnail;
|
||||
final CollectionActionType actionType;
|
||||
final bool showOptionToCreateNewAlbum;
|
||||
final SelectedFiles? selectedFiles;
|
||||
final List<SharedMediaFile>? sharedFiles;
|
||||
final String searchQuery;
|
||||
|
@ -39,7 +38,6 @@ class CollectionsListWidget extends StatelessWidget {
|
|||
CollectionsListWidget(
|
||||
this.collectionsWithThumbnail,
|
||||
this.actionType,
|
||||
this.showOptionToCreateNewAlbum,
|
||||
this.selectedFiles,
|
||||
this.sharedFiles,
|
||||
this.searchQuery,
|
||||
|
@ -56,18 +54,15 @@ class CollectionsListWidget extends StatelessWidget {
|
|||
: selectedFiles?.files.length ?? 0;
|
||||
|
||||
if (collectionsWithThumbnail.isEmpty) {
|
||||
if (shouldShowCreateAlbum) {
|
||||
return _getNewAlbumWidget(context, filesCount);
|
||||
}
|
||||
return const EmptyState();
|
||||
}
|
||||
return ListView.separated(
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 && shouldShowCreateAlbum) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await _createNewAlbumOnTap(context, filesCount);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const NewAlbumListItemWidget(),
|
||||
);
|
||||
return _getNewAlbumWidget(context, filesCount);
|
||||
}
|
||||
final item =
|
||||
collectionsWithThumbnail[index - (shouldShowCreateAlbum ? 1 : 0)];
|
||||
|
@ -89,6 +84,16 @@ class CollectionsListWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
GestureDetector _getNewAlbumWidget(BuildContext context, int filesCount) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await _createNewAlbumOnTap(context, filesCount);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const NewAlbumListItemWidget(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createNewAlbumOnTap(
|
||||
BuildContext context,
|
||||
int filesCount,
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class DividerWithPadding extends StatelessWidget {
|
||||
final double left, top, right, bottom, thickness;
|
||||
|
||||
const DividerWithPadding({
|
||||
Key? key,
|
||||
this.left = 0,
|
||||
this.top = 0,
|
||||
this.right = 0,
|
||||
this.bottom = 0,
|
||||
this.thickness = 0.5,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(left, top, right, bottom),
|
||||
child: Divider(
|
||||
thickness: thickness,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,17 +3,25 @@ import 'package:photos/theme/ente_theme.dart';
|
|||
|
||||
class EnteLoadingWidget extends StatelessWidget {
|
||||
final Color? color;
|
||||
final bool is20pts;
|
||||
const EnteLoadingWidget({this.is20pts = false, this.color, Key? key})
|
||||
: super(key: key);
|
||||
final double size;
|
||||
final double padding;
|
||||
final Alignment alignment;
|
||||
const EnteLoadingWidget({
|
||||
this.color,
|
||||
this.size = 14,
|
||||
this.padding = 5,
|
||||
this.alignment = Alignment.center,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(is20pts ? 3 : 5),
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: SizedBox.fromSize(
|
||||
size: const Size.square(14),
|
||||
size: Size.square(size),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: color ?? getEnteColorScheme(context).strokeBase,
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
|
||||
class RenameDialog extends StatefulWidget {
|
||||
final String? name;
|
||||
final String type;
|
||||
final int maxLength;
|
||||
|
||||
const RenameDialog(this.name, this.type, {Key? key, this.maxLength = 100})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<RenameDialog> createState() => _RenameDialogState();
|
||||
}
|
||||
|
||||
class _RenameDialogState extends State<RenameDialog> {
|
||||
String? _newName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_newName = widget.name;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Enter a new name"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
decoration: InputDecoration(
|
||||
hintText: '${widget.type} name',
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.white30,
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_newName = value;
|
||||
});
|
||||
},
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.text,
|
||||
initialValue: _newName,
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text(
|
||||
"Cancel",
|
||||
style: TextStyle(
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
"Rename",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
if (_newName!.trim().isEmpty) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Empty name",
|
||||
"${widget.type} name cannot be empty",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_newName!.trim().length > widget.maxLength) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
"Name too large",
|
||||
"${widget.type} name should be less than ${widget.maxLength} characters",
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(_newName!.trim());
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import "package:photos/models/search/button_result.dart";
|
|||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/effects.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/utils/separators_util.dart';
|
||||
|
||||
enum ActionSheetType {
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:photos/models/selected_files.dart';
|
|||
import 'package:photos/theme/effects.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/bottom_action_bar/action_bar_widget.dart';
|
||||
import 'package:photos/ui/components/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
|
||||
class BottomActionBarWidget extends StatelessWidget {
|
||||
final String? text;
|
||||
|
|
|
@ -350,7 +350,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
|||
},
|
||||
),
|
||||
EnteLoadingWidget(
|
||||
is20pts: true,
|
||||
padding: 3,
|
||||
color: loadingIconColor,
|
||||
),
|
||||
],
|
54
lib/ui/components/buttons/chip_button_widget.dart
Normal file
54
lib/ui/components/buttons/chip_button_widget.dart
Normal file
|
@ -0,0 +1,54 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
|
||||
///https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?node-id=8119%3A59513&t=gQa1to5jY89Qk1k7-4
|
||||
class ChipButtonWidget extends StatelessWidget {
|
||||
final String? label;
|
||||
final IconData? leadingIcon;
|
||||
final VoidCallback? onTap;
|
||||
final bool noChips;
|
||||
const ChipButtonWidget(
|
||||
this.label, {
|
||||
this.leadingIcon,
|
||||
this.onTap,
|
||||
this.noChips = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap?.call,
|
||||
child: Container(
|
||||
width: noChips ? double.infinity : null,
|
||||
decoration: BoxDecoration(
|
||||
color: getEnteColorScheme(context).fillFaint,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
leadingIcon != null
|
||||
? Icon(
|
||||
leadingIcon,
|
||||
size: 17,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
label ?? "",
|
||||
style: getEnteTextTheme(context).smallBold,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -43,6 +43,7 @@ class _IconButtonWidgetState extends State<IconButtonWidget> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasPressedState = widget.onTap != null;
|
||||
final colorTheme = getEnteColorScheme(context);
|
||||
iconStateColor ??
|
||||
(iconStateColor = widget.defaultColor ??
|
||||
|
@ -52,9 +53,9 @@ class _IconButtonWidgetState extends State<IconButtonWidget> {
|
|||
return widget.disableGestureDetector
|
||||
? _iconButton(colorTheme)
|
||||
: GestureDetector(
|
||||
onTapDown: _onTapDown,
|
||||
onTapUp: _onTapUp,
|
||||
onTapCancel: _onTapCancel,
|
||||
onTapDown: hasPressedState ? _onTapDown : null,
|
||||
onTapUp: hasPressedState ? _onTapUp : null,
|
||||
onTapCancel: hasPressedState ? _onTapCancel : null,
|
||||
onTap: widget.onTap,
|
||||
child: _iconButton(colorTheme),
|
||||
);
|
20
lib/ui/components/buttons/inline_button_widget.dart
Normal file
20
lib/ui/components/buttons/inline_button_widget.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import "package:flutter/cupertino.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
|
||||
class InlineButtonWidget extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final TextStyle? textStyle;
|
||||
const InlineButtonWidget(this.label, this.onTap, {this.textStyle, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap?.call,
|
||||
child: Text(
|
||||
label,
|
||||
style: textStyle ?? getEnteTextTheme(context).smallMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import 'package:photos/models/typedefs.dart';
|
|||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/effects.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/components/text_input_widget.dart';
|
||||
import 'package:photos/utils/separators_util.dart';
|
||||
|
|
|
@ -11,15 +11,21 @@ enum DividerType {
|
|||
class DividerWidget extends StatelessWidget {
|
||||
final DividerType dividerType;
|
||||
final Color bgColor;
|
||||
final bool divColorHasBlur;
|
||||
final EdgeInsets? padding;
|
||||
const DividerWidget({
|
||||
required this.dividerType,
|
||||
this.bgColor = Colors.transparent,
|
||||
this.divColorHasBlur = true,
|
||||
this.padding,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dividerColor = getEnteColorScheme(context).blurStrokeFaint;
|
||||
final dividerColor = divColorHasBlur
|
||||
? getEnteColorScheme(context).blurStrokeFaint
|
||||
: getEnteColorScheme(context).strokeFaint;
|
||||
|
||||
if (dividerType == DividerType.solid) {
|
||||
return Container(
|
||||
|
@ -38,6 +44,7 @@ class DividerWidget extends StatelessWidget {
|
|||
|
||||
return Container(
|
||||
color: bgColor,
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/ui/components/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/viewer/search/search_widget.dart';
|
||||
|
||||
class HomeHeaderWidget extends StatefulWidget {
|
||||
|
|
106
lib/ui/components/info_item_widget.dart
Normal file
106
lib/ui/components/info_item_widget.dart
Normal file
|
@ -0,0 +1,106 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
|
||||
///https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?node-id=8113-59605&t=OMX5f5KdDJYWSQQN-4
|
||||
class InfoItemWidget extends StatelessWidget {
|
||||
final IconData leadingIcon;
|
||||
final VoidCallback? editOnTap;
|
||||
final String? title;
|
||||
final Future<List<Widget>> subtitleSection;
|
||||
final bool hasChipButtons;
|
||||
const InfoItemWidget({
|
||||
required this.leadingIcon,
|
||||
this.editOnTap,
|
||||
this.title,
|
||||
required this.subtitleSection,
|
||||
this.hasChipButtons = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = <Widget>[];
|
||||
if (title != null) {
|
||||
children.addAll([
|
||||
Text(
|
||||
title!,
|
||||
style: hasChipButtons
|
||||
? getEnteTextTheme(context).smallMuted
|
||||
: getEnteTextTheme(context).body,
|
||||
),
|
||||
SizedBox(height: hasChipButtons ? 8 : 4),
|
||||
]);
|
||||
}
|
||||
|
||||
children.addAll([
|
||||
Flexible(
|
||||
child: FutureBuilder(
|
||||
future: subtitleSection,
|
||||
builder: (context, snapshot) {
|
||||
Widget child;
|
||||
if (snapshot.hasData) {
|
||||
final subtitle = snapshot.data as List<Widget>;
|
||||
if (subtitle.isNotEmpty) {
|
||||
child = Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: subtitle,
|
||||
);
|
||||
} else {
|
||||
child = const SizedBox.shrink();
|
||||
}
|
||||
} else {
|
||||
child = EnteLoadingWidget(
|
||||
padding: 3,
|
||||
size: 11,
|
||||
color: getEnteColorScheme(context).strokeMuted,
|
||||
alignment: Alignment.centerLeft,
|
||||
);
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeInOutExpo,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButtonWidget(
|
||||
icon: leadingIcon,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 3.5, 16, 3.5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
editOnTap != null
|
||||
? IconButtonWidget(
|
||||
icon: Icons.edit,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
onTap: editOnTap,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/text_style.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
|
||||
enum ButtonType {
|
||||
primary,
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:photos/ente_theme_data.dart';
|
|||
import 'package:photos/theme/colors.dart';
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import 'package:photos/theme/text_style.dart';
|
||||
import 'package:photos/ui/components/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
|
||||
// CreateNotificationType enum
|
||||
enum NotificationType {
|
||||
|
|
|
@ -77,9 +77,11 @@ class _TextInputWidgetState extends State<TextInputWidget> {
|
|||
selection: TextSelection.collapsed(offset: widget.initialValue!.length),
|
||||
);
|
||||
}
|
||||
_textController.addListener(() {
|
||||
widget.onChange!.call(_textController.text);
|
||||
});
|
||||
if (widget.onChange != null) {
|
||||
_textController.addListener(() {
|
||||
widget.onChange!.call(_textController.text);
|
||||
});
|
||||
}
|
||||
_obscureTextNotifier = ValueNotifier(widget.isPasswordInput);
|
||||
_obscureTextNotifier.addListener(_safeRefresh);
|
||||
super.initState();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
|
||||
class TitleBarWidget extends StatelessWidget {
|
||||
final IconButtonWidget? leading;
|
||||
|
|
|
@ -5,8 +5,8 @@ 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/components/button_widget.dart";
|
||||
import "package:photos/ui/components/icon_button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/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";
|
||||
|
@ -135,9 +135,10 @@ class _ApplyCodeScreenState extends State<ApplyCodeScreen> {
|
|||
Logger('$runtimeType')
|
||||
.severe("failed to apply referral", e);
|
||||
showErrorDialogForException(
|
||||
context: context,
|
||||
exception: e as Exception,
|
||||
apiErrorPrefix: "Failed to apply code");
|
||||
context: context,
|
||||
exception: e as Exception,
|
||||
apiErrorPrefix: "Failed to apply code",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
@ -3,8 +3,8 @@ import "package:flutter_animate/flutter_animate.dart";
|
|||
import "package:photos/models/api/storage_bonus/storage_bonus.dart";
|
||||
import "package:photos/models/user_details.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/components/captioned_text_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";
|
||||
|
|
|
@ -6,9 +6,9 @@ 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/buttons/icon_button_widget.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";
|
||||
|
@ -43,7 +43,7 @@ class _ReferralScreenState extends State<ReferralScreen> {
|
|||
await UserService.instance.getUserDetailsV2(memoryCount: false);
|
||||
final referralView =
|
||||
await StorageBonusService.instance.getGateway().getReferralView();
|
||||
return Tuple2(referralView, cachedUserDetails!);
|
||||
return Tuple2(referralView, cachedUserDetails);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -6,7 +6,7 @@ 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/buttons/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";
|
||||
|
|
|
@ -10,7 +10,7 @@ import 'package:photos/ui/account/login_page.dart';
|
|||
import 'package:photos/ui/account/password_entry_page.dart';
|
||||
import 'package:photos/ui/account/password_reentry_page.dart';
|
||||
import 'package:photos/ui/common/gradient_button.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/dialog_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/payment/subscription.dart';
|
||||
|
|
|
@ -2,9 +2,9 @@ import "dart:io";
|
|||
|
||||
import "package:flutter/cupertino.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import 'package:photos/models/memory.dart';
|
||||
import 'package:photos/services/memories_service.dart';
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/theme/text_style.dart";
|
||||
import "package:photos/ui/actions/file/file_actions.dart";
|
||||
import "package:photos/ui/extents_page_view.dart";
|
||||
|
@ -120,10 +120,7 @@ class _MemoryWidgetState extends State<MemoryWidget> {
|
|||
type: MaterialType.transparency,
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.subtitle1!
|
||||
.copyWith(fontSize: 12),
|
||||
style: getEnteTextTheme(context).mini,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
@ -136,22 +133,21 @@ class _MemoryWidgetState extends State<MemoryWidget> {
|
|||
}
|
||||
|
||||
Container _buildMemoryItem(BuildContext context, int index) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final memory = widget.memories[index];
|
||||
final isSeen = memory.isSeen();
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: isSeen
|
||||
? const Border()
|
||||
: Border.all(
|
||||
color: Theme.of(context).colorScheme.greenAlternative,
|
||||
width: isSeen ? 0 : 2,
|
||||
),
|
||||
border: Border.all(
|
||||
color: isSeen ? colorScheme.strokeFaint : colorScheme.primary500,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: isSeen ? 60 : 56,
|
||||
height: isSeen ? 60 : 56,
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Hero(
|
||||
tag: "memories" + memory.file.tag,
|
||||
child: ThumbnailWidget(
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import 'package:photos/events/notification_event.dart';
|
||||
|
@ -24,6 +25,8 @@ class StatusBarWidget extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _StatusBarWidgetState extends State<StatusBarWidget> {
|
||||
static final _logger = Logger("StatusBarWidget");
|
||||
|
||||
late StreamSubscription<SyncStatusUpdate> _subscription;
|
||||
late StreamSubscription<NotificationEvent> _notificationSubscription;
|
||||
bool _showStatus = false;
|
||||
|
@ -33,6 +36,7 @@ class _StatusBarWidgetState extends State<StatusBarWidget> {
|
|||
@override
|
||||
void initState() {
|
||||
_subscription = Bus.instance.on<SyncStatusUpdate>().listen((event) {
|
||||
_logger.info("Received event " + event.toString());
|
||||
if (event.status == SyncStatus.error) {
|
||||
setState(() {
|
||||
_syncError = event.error;
|
||||
|
|
|
@ -2,7 +2,7 @@ import "package:flutter/material.dart";
|
|||
import "package:photos/core/constants.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/collection_action_sheet.dart";
|
||||
import "package:photos/ui/components/button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import "package:photos/ui/components/empty_state_item_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/utils/share_util.dart";
|
||||
|
|
|
@ -3,7 +3,7 @@ import "dart:io";
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/services/update_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/divider_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/models/user_details.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
|
||||
class ChildSubscriptionWidget extends StatelessWidget {
|
||||
|
|
|
@ -384,12 +384,6 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
|
|||
margin: const EdgeInsets.only(bottom: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
_isFreePlanUser()
|
||||
? Text(
|
||||
"2 months free on yearly plans",
|
||||
style: getEnteTextTheme(context).miniMuted,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
RepaintBoundary(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
@ -405,10 +399,17 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
|
|||
await _filterStorePlansForUi();
|
||||
},
|
||||
),
|
||||
planText("Yearly", !showYearlyPlan)
|
||||
planText("Yearly", !showYearlyPlan),
|
||||
],
|
||||
),
|
||||
),
|
||||
_isFreePlanUser()
|
||||
? Text(
|
||||
"2 months free on yearly plans",
|
||||
style: getEnteTextTheme(context).miniMuted,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import "package:flutter/foundation.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import 'package:photos/models/billing_plan.dart';
|
||||
import 'package:photos/models/subscription.dart';
|
||||
|
@ -14,7 +15,7 @@ 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/buttons/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';
|
||||
|
@ -55,6 +56,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
|
|||
bool _isStripeSubscriber = false;
|
||||
bool _showYearlyPlan = false;
|
||||
EnteColorScheme colorScheme = darkScheme;
|
||||
final Logger logger = Logger("StripeSubscriptionPage");
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -366,20 +368,44 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> toggleStripeSubscription(bool isRenewCancelled) async {
|
||||
// toggleStripeSubscription, based on current auto renew status, will
|
||||
// toggle the auto renew status of the user's subscription
|
||||
Future<void> toggleStripeSubscription(bool isAutoRenewDisabled) async {
|
||||
await _dialog.show();
|
||||
try {
|
||||
isRenewCancelled
|
||||
isAutoRenewDisabled
|
||||
? await _billingService.activateStripeSubscription()
|
||||
: await _billingService.cancelStripeSubscription();
|
||||
await _fetchSub();
|
||||
} catch (e) {
|
||||
showShortToast(
|
||||
context,
|
||||
isRenewCancelled ? 'Failed to renew' : 'Failed to cancel',
|
||||
isAutoRenewDisabled ? 'Failed to renew' : 'Failed to cancel',
|
||||
);
|
||||
}
|
||||
await _dialog.hide();
|
||||
if (!isAutoRenewDisabled && mounted) {
|
||||
await showTextInputDialog(
|
||||
context,
|
||||
title: "Your subscription was cancelled. Would you like to share the "
|
||||
"reason?",
|
||||
submitButtonLabel: "Send",
|
||||
hintText: "Optional, as short as you like...",
|
||||
alwaysShowSuccessState: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
onSubmit: (String text) async {
|
||||
// indicates user cancelled the rename request
|
||||
if (text == "" || text.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await UserService.instance.sendFeedback(context, text);
|
||||
} catch (e, s) {
|
||||
logger.severe("Failed to send feedback", e, s);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _getStripePlanWidgets() {
|
||||
|
@ -492,12 +518,6 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
|
|||
margin: const EdgeInsets.only(bottom: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
_isFreePlanUser()
|
||||
? Text(
|
||||
"2 months free on yearly plans",
|
||||
style: getEnteTextTheme(context).miniMuted,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
RepaintBoundary(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
@ -513,10 +533,17 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
|
|||
await _filterStripeForUI();
|
||||
},
|
||||
),
|
||||
planText("Yearly", !_showYearlyPlan)
|
||||
planText("Yearly", !_showYearlyPlan),
|
||||
],
|
||||
),
|
||||
),
|
||||
_isFreePlanUser()
|
||||
? Text(
|
||||
"2 months free on yearly plans",
|
||||
style: getEnteTextTheme(context).miniMuted,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -7,14 +7,12 @@ import 'package:photos/theme/ente_theme.dart';
|
|||
import 'package:photos/ui/account/change_email_dialog.dart';
|
||||
import 'package:photos/ui/account/delete_account_page.dart';
|
||||
import 'package:photos/ui/account/password_entry_page.dart';
|
||||
import 'package:photos/ui/account/recovery_key_page.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/payment/subscription.dart";
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import "package:url_launcher/url_launcher_string.dart";
|
||||
|
||||
class AccountSectionWidget extends StatelessWidget {
|
||||
|
@ -35,38 +33,13 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Recovery key",
|
||||
title: "Manage subscription",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
showOnlyLoadingState: true,
|
||||
onTap: () async {
|
||||
final hasAuthenticated = await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(
|
||||
context,
|
||||
"Please authenticate to view your recovery key",
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
String recoveryKey;
|
||||
try {
|
||||
recoveryKey = await _getOrCreateRecoveryKey(context);
|
||||
} catch (e) {
|
||||
await showGenericErrorDialog(context: context);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
RecoveryKeyPage(
|
||||
recoveryKey,
|
||||
"OK",
|
||||
showAppBar: true,
|
||||
onDone: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_onManageSubscriptionTapped(context);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
|
@ -157,7 +130,22 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
routeToPage(context, const DeleteAccountPage());
|
||||
final hasAuthenticated = await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(
|
||||
context,
|
||||
"Please authenticate to initiate account deletion",
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
unawaited(
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return const DeleteAccountPage();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
|
@ -165,12 +153,6 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Future<String> _getOrCreateRecoveryKey(BuildContext context) async {
|
||||
return CryptoUtil.bin2hex(
|
||||
await UserService.instance.getOrCreateRecoveryKey(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _onLogoutTapped(BuildContext context) {
|
||||
showChoiceActionSheet(
|
||||
context,
|
||||
|
@ -182,4 +164,14 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onManageSubscriptionTapped(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return getSubscriptionPage();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ 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';
|
||||
|
||||
|
@ -26,18 +25,6 @@ class GeneralSectionWidget extends StatelessWidget {
|
|||
Widget _getSectionOptions(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Manage subscription",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
onTap: () async {
|
||||
_onManageSubscriptionTapped(context);
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
@ -84,16 +71,6 @@ class GeneralSectionWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _onManageSubscriptionTapped(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return getSubscriptionPage();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onFamilyPlansTapped(BuildContext context) async {
|
||||
final userDetails =
|
||||
await UserService.instance.getUserDetailsV2(memoryCount: false);
|
||||
|
|
|
@ -8,12 +8,16 @@ import 'package:photos/events/two_factor_status_change_event.dart';
|
|||
import 'package:photos/services/local_authentication_service.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import "package:photos/ui/account/recovery_key_page.dart";
|
||||
import 'package:photos/ui/account/sessions_page.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/components/toggle_switch_widget.dart';
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import "package:photos/utils/crypto_util.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
|
||||
class SecuritySectionWidget extends StatefulWidget {
|
||||
const SecuritySectionWidget({Key? key}) : super(key: key);
|
||||
|
@ -60,6 +64,43 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
|
|||
if (_config.hasConfiguredAccount()) {
|
||||
children.addAll(
|
||||
[
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
title: "Recovery key",
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
showOnlyLoadingState: true,
|
||||
onTap: () async {
|
||||
final hasAuthenticated = await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(
|
||||
context,
|
||||
"Please authenticate to view your recovery key",
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
String recoveryKey;
|
||||
try {
|
||||
recoveryKey = await _getOrCreateRecoveryKey(context);
|
||||
} catch (e) {
|
||||
await showGenericErrorDialog(context: context);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
RecoveryKeyPage(
|
||||
recoveryKey,
|
||||
"OK",
|
||||
showAppBar: true,
|
||||
onDone: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: const CaptionedTextWidget(
|
||||
|
@ -186,4 +227,10 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _getOrCreateRecoveryKey(BuildContext context) async {
|
||||
return CryptoUtil.bin2hex(
|
||||
await UserService.instance.getOrCreateRecoveryKey(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:photos/models/collection.dart';
|
|||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/divider_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:photos/services/collections_service.dart';
|
|||
import 'package:photos/theme/colors.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/divider_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
|
|
|
@ -10,7 +10,7 @@ import "package:photos/core/configuration.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/components/button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/utils/share_util.dart";
|
||||
|
||||
|
|
|
@ -32,11 +32,13 @@ class AppLock extends StatefulWidget {
|
|||
final Duration backgroundLockLatency;
|
||||
final ThemeData? darkTheme;
|
||||
final ThemeData? lightTheme;
|
||||
final ThemeMode savedThemeMode;
|
||||
|
||||
const AppLock({
|
||||
Key? key,
|
||||
required this.builder,
|
||||
required this.lockScreen,
|
||||
required this.savedThemeMode,
|
||||
this.enabled = true,
|
||||
this.backgroundLockLatency = const Duration(seconds: 0),
|
||||
this.darkTheme,
|
||||
|
@ -103,7 +105,7 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
|
|||
return MaterialApp(
|
||||
home: this.widget.enabled ? this._lockScreen : this.widget.builder(null),
|
||||
navigatorKey: _navigatorKey,
|
||||
themeMode: ThemeMode.system,
|
||||
themeMode: widget.savedThemeMode,
|
||||
theme: widget.lightTheme,
|
||||
darkTheme: widget.darkTheme,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
|
|
@ -7,8 +7,8 @@ import 'package:photos/core/cache/video_cache_manager.dart';
|
|||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/services/feature_flag_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_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/menu_section_title.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
|
|
|
@ -16,7 +16,7 @@ import 'package:photos/models/location.dart';
|
|||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/tools/editor/filtered_image.dart';
|
||||
import 'package:photos/ui/viewer/file/detail_page.dart';
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:photos/models/collection.dart';
|
|||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
|
||||
class DeleteEmptyAlbums extends StatefulWidget {
|
||||
|
|
|
@ -18,7 +18,7 @@ import 'package:photos/ui/collection_action_sheet.dart';
|
|||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/blur_menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/bottom_action_bar/expanded_menu_widget.dart';
|
||||
import 'package:photos/ui/components/button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/sharing/manage_links_widget.dart';
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
|
@ -126,7 +126,7 @@ class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
|
|||
firstList.add(
|
||||
BlurMenuItemWidget(
|
||||
leadingIcon: Icons.link_outlined,
|
||||
labelText: "Create link$suffix",
|
||||
labelText: "Share link$suffix",
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
onTap: anyUploadedFiles ? _onCreatedSharedLinkClicked : null,
|
||||
),
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:photos/models/selected_files.dart';
|
|||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/collection_action_sheet.dart';
|
||||
import 'package:photos/ui/components/bottom_action_bar/bottom_action_bar_widget.dart';
|
||||
import 'package:photos/ui/components/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/viewer/actions/file_selection_actions_widget.dart';
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
import 'package:photos/utils/magic_util.dart';
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/models/collection.dart';
|
||||
import 'package:photos/models/collection_items.dart';
|
||||
import 'package:photos/models/gallery_type.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/file_info_collection_widget.dart';
|
||||
import 'package:photos/ui/viewer/gallery/collection_page.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class CollectionsListOfFileWidget extends StatelessWidget {
|
||||
final Future<Set<int>> allCollectionIDsOfFile;
|
||||
final int currentUserID;
|
||||
|
||||
const CollectionsListOfFileWidget(
|
||||
this.allCollectionIDsOfFile,
|
||||
this.currentUserID, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Set<int>>(
|
||||
future: allCollectionIDsOfFile,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final Set<int> collectionIDs = snapshot.data!;
|
||||
final collections = <Collection>[];
|
||||
for (var collectionID in collectionIDs) {
|
||||
final c =
|
||||
CollectionsService.instance.getCollectionByID(collectionID);
|
||||
collections.add(c!);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: collections.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
final bool isHidden = collections[index].isHidden();
|
||||
return FileInfoCollectionWidget(
|
||||
name: isHidden ? 'Hidden' : collections[index].name,
|
||||
onTap: () {
|
||||
if (isHidden) {
|
||||
return;
|
||||
}
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionPage(
|
||||
CollectionWithThumbnail(collections[index], null),
|
||||
appBarType: collections[index].isOwner(currentUserID)
|
||||
? GalleryType.ownedCollection
|
||||
: GalleryType.sharedCollection,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
Logger("CollectionsListOfFile").info(snapshot.error);
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/file_info_collection_widget.dart';
|
||||
|
||||
class DeviceFoldersListOfFileWidget extends StatelessWidget {
|
||||
final Future<Set<String>> allDeviceFoldersOfFile;
|
||||
const DeviceFoldersListOfFileWidget(this.allDeviceFoldersOfFile, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Set<String>>(
|
||||
future: allDeviceFoldersOfFile,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final List<String> deviceFolders = snapshot.data!.toList();
|
||||
return ListView.builder(
|
||||
itemCount: deviceFolders.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return FileInfoCollectionWidget(
|
||||
name: deviceFolders[index],
|
||||
onTap: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
Logger("DeviceFoldersListOfFile").info(snapshot.error);
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,31 +2,34 @@ import 'dart:ui';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/utils/exif_util.dart';
|
||||
|
||||
class ExifInfoDialog extends StatefulWidget {
|
||||
class ExifInfoDialog extends StatelessWidget {
|
||||
final File file;
|
||||
const ExifInfoDialog(this.file, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExifInfoDialog> createState() => _ExifInfoDialogState();
|
||||
}
|
||||
|
||||
class _ExifInfoDialogState extends State<ExifInfoDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollController = ScrollController();
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
widget.file.title!,
|
||||
style: Theme.of(context).textTheme.headline5,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"EXIF",
|
||||
style: textTheme.h3Bold,
|
||||
),
|
||||
Text(
|
||||
file.title!,
|
||||
style: textTheme.smallMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Scrollbar(
|
||||
controller: scrollController,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: _getInfo(),
|
||||
),
|
||||
),
|
||||
|
@ -34,7 +37,7 @@ class _ExifInfoDialogState extends State<ExifInfoDialog> {
|
|||
TextButton(
|
||||
child: Text(
|
||||
"Close",
|
||||
style: Theme.of(context).textTheme.bodyText1,
|
||||
style: textTheme.body,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context, rootNavigator: true).pop('dialog');
|
||||
|
@ -46,7 +49,7 @@ class _ExifInfoDialogState extends State<ExifInfoDialog> {
|
|||
|
||||
Widget _getInfo() {
|
||||
return FutureBuilder(
|
||||
future: getExif(widget.file),
|
||||
future: getExif(file),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final exif = snapshot.data;
|
||||
|
|
255
lib/ui/viewer/file/file_details_widget.dart
Normal file
255
lib/ui/viewer/file/file_details_widget.dart
Normal file
|
@ -0,0 +1,255 @@
|
|||
import "package:exif/exif.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/models/file.dart";
|
||||
import "package:photos/models/file_type.dart";
|
||||
import "package:photos/services/feature_flag_service.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/components/divider_widget.dart";
|
||||
import 'package:photos/ui/components/title_bar_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/file_caption_widget.dart';
|
||||
import "package:photos/ui/viewer/file_details/added_by_widget.dart";
|
||||
import "package:photos/ui/viewer/file_details/albums_item_widget.dart";
|
||||
import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart';
|
||||
import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart";
|
||||
import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart';
|
||||
import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart";
|
||||
import "package:photos/ui/viewer/file_details/objects_item_widget.dart";
|
||||
import "package:photos/utils/exif_util.dart";
|
||||
|
||||
class FileDetailsWidget extends StatefulWidget {
|
||||
final File file;
|
||||
const FileDetailsWidget(
|
||||
this.file, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<FileDetailsWidget> createState() => _FileDetailsWidgetState();
|
||||
}
|
||||
|
||||
class _FileDetailsWidgetState extends State<FileDetailsWidget> {
|
||||
final ValueNotifier<Map<String, IfdTag>?> _exifNotifier = ValueNotifier(null);
|
||||
final Map<String, dynamic> _exifData = {
|
||||
"focalLength": null,
|
||||
"fNumber": null,
|
||||
"resolution": null,
|
||||
"takenOnDevice": null,
|
||||
"exposureTime": null,
|
||||
"ISO": null,
|
||||
"megaPixels": null
|
||||
};
|
||||
|
||||
bool _isImage = false;
|
||||
late int _currentUserID;
|
||||
bool showExifListTile = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
debugPrint('file_details_sheet initState');
|
||||
_currentUserID = Configuration.instance.getUserID()!;
|
||||
_isImage = widget.file.fileType == FileType.image ||
|
||||
widget.file.fileType == FileType.livePhoto;
|
||||
if (_isImage) {
|
||||
_exifNotifier.addListener(() {
|
||||
if (_exifNotifier.value != null) {
|
||||
_generateExifForDetails(_exifNotifier.value!);
|
||||
}
|
||||
showExifListTile = _exifData["focalLength"] != null ||
|
||||
_exifData["fNumber"] != null ||
|
||||
_exifData["takenOnDevice"] != null ||
|
||||
_exifData["exposureTime"] != null ||
|
||||
_exifData["ISO"] != null;
|
||||
});
|
||||
getExif(widget.file).then((exif) {
|
||||
_exifNotifier.value = exif;
|
||||
});
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_exifNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = widget.file;
|
||||
final bool isFileOwner =
|
||||
file.ownerID == null || file.ownerID == _currentUserID;
|
||||
|
||||
//Make sure the bottom most tile is always the same one, that is it should
|
||||
//not be rendered only if a condition is met.
|
||||
final fileDetailsTiles = <Widget>[];
|
||||
fileDetailsTiles.add(
|
||||
!widget.file.isUploaded ||
|
||||
(!isFileOwner && (widget.file.caption?.isEmpty ?? true))
|
||||
? const SizedBox(height: 16)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
child: isFileOwner
|
||||
? FileCaptionWidget(file: widget.file)
|
||||
: FileCaptionReadyOnly(caption: widget.file.caption!),
|
||||
),
|
||||
);
|
||||
fileDetailsTiles.addAll([
|
||||
CreationTimeItem(file, _currentUserID),
|
||||
const FileDetailsDivider(),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _exifNotifier,
|
||||
builder: (context, _, __) => FilePropertiesItemWidget(
|
||||
file,
|
||||
_isImage,
|
||||
_exifData,
|
||||
_currentUserID,
|
||||
),
|
||||
),
|
||||
const FileDetailsDivider(),
|
||||
]);
|
||||
fileDetailsTiles.add(
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _exifNotifier,
|
||||
builder: (context, value, _) {
|
||||
return showExifListTile
|
||||
? Column(
|
||||
children: [
|
||||
BasicExifItemWidget(_exifData),
|
||||
const FileDetailsDivider(),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
if (_isImage) {
|
||||
fileDetailsTiles.addAll([
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _exifNotifier,
|
||||
builder: (context, value, _) {
|
||||
return Column(
|
||||
children: [
|
||||
AllExifItemWidget(file, _exifNotifier.value),
|
||||
const FileDetailsDivider(),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
]);
|
||||
}
|
||||
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
|
||||
fileDetailsTiles.addAll([
|
||||
ObjectsItemWidget(file),
|
||||
const FileDetailsDivider(),
|
||||
]);
|
||||
}
|
||||
if (file.uploadedFileID != null && file.updationTime != null) {
|
||||
fileDetailsTiles.addAll(
|
||||
[
|
||||
BackedUpTimeItemWidget(file),
|
||||
const FileDetailsDivider(),
|
||||
],
|
||||
);
|
||||
}
|
||||
fileDetailsTiles.add(AlbumsItemWidget(file, _currentUserID));
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Scrollbar(
|
||||
thickness: 4,
|
||||
radius: const Radius.circular(2),
|
||||
thumbVisibility: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
slivers: <Widget>[
|
||||
TitleBarWidget(
|
||||
isFlexibleSpaceDisabled: true,
|
||||
title: "Details",
|
||||
isOnTopOfScreen: false,
|
||||
backgroundColor: getEnteColorScheme(context).backgroundElevated,
|
||||
leading: IconButtonWidget(
|
||||
icon: Icons.expand_more_outlined,
|
||||
iconButtonType: IconButtonType.primary,
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: AddedByWidget(
|
||||
widget.file,
|
||||
_currentUserID,
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return fileDetailsTiles[index];
|
||||
},
|
||||
childCount: fileDetailsTiles.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_generateExifForDetails(Map<String, IfdTag> exif) {
|
||||
if (exif["EXIF FocalLength"] != null) {
|
||||
_exifData["focalLength"] =
|
||||
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
|
||||
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
|
||||
.denominator;
|
||||
}
|
||||
|
||||
if (exif["EXIF FNumber"] != null) {
|
||||
_exifData["fNumber"] =
|
||||
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
|
||||
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
|
||||
}
|
||||
final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
|
||||
final imageLength = exif["EXIF ExifImageLength"] ??
|
||||
exif["Image "
|
||||
"ImageLength"];
|
||||
if (imageWidth != null && imageLength != null) {
|
||||
_exifData["resolution"] = '$imageWidth x $imageLength';
|
||||
_exifData['megaPixels'] =
|
||||
((imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
|
||||
1000000)
|
||||
.toStringAsFixed(1);
|
||||
} else {
|
||||
debugPrint("No image width/height");
|
||||
}
|
||||
if (exif["Image Make"] != null && exif["Image Model"] != null) {
|
||||
_exifData["takenOnDevice"] =
|
||||
exif["Image Make"].toString() + " " + exif["Image Model"].toString();
|
||||
}
|
||||
|
||||
if (exif["EXIF ExposureTime"] != null) {
|
||||
_exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
|
||||
}
|
||||
if (exif["EXIF ISOSpeedRatings"] != null) {
|
||||
_exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileDetailsDivider extends StatelessWidget {
|
||||
const FileDetailsDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const dividerPadding = EdgeInsets.symmetric(vertical: 15.5);
|
||||
return const DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
divColorHasBlur: false,
|
||||
padding: dividerPadding,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
|
||||
class FileInfoCollectionWidget extends StatelessWidget {
|
||||
final String? name;
|
||||
final Function? onTap;
|
||||
const FileInfoCollectionWidget({this.name, this.onTap, Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap as void Function()?,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: 10,
|
||||
bottom: 18,
|
||||
right: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.inverseBackgroundColor
|
||||
.withOpacity(0.025),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
name!,
|
||||
style: Theme.of(context).textTheme.subtitle2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,536 +0,0 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:exif/exif.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import 'package:photos/db/files_db.dart';
|
||||
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/services/location_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';
|
||||
import 'package:photos/ui/components/title_bar_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/collections_list_of_file_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/device_folders_list_of_file_widget.dart';
|
||||
import 'package:photos/ui/viewer/file/file_caption_widget.dart';
|
||||
import "package:photos/ui/viewer/file/location_chip.dart";
|
||||
import "package:photos/ui/viewer/file/locations_list.dart";
|
||||
import "package:photos/ui/viewer/file/object_tags_widget.dart";
|
||||
import 'package:photos/ui/viewer/file/raw_exif_list_tile_widget.dart';
|
||||
import "package:photos/utils/date_time_util.dart";
|
||||
import "package:photos/utils/exif_util.dart";
|
||||
import "package:photos/utils/file_util.dart";
|
||||
import "package:photos/utils/magic_util.dart";
|
||||
|
||||
class FileInfoWidget extends StatefulWidget {
|
||||
final File file;
|
||||
|
||||
const FileInfoWidget(
|
||||
this.file, {
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<FileInfoWidget> createState() => _FileInfoWidgetState();
|
||||
}
|
||||
|
||||
class _FileInfoWidgetState extends State<FileInfoWidget> {
|
||||
Map<String, IfdTag>? _exif;
|
||||
late LocationService locationService = LocationService.instance;
|
||||
final Map<String, dynamic> _exifData = {
|
||||
"focalLength": null,
|
||||
"fNumber": null,
|
||||
"resolution": null,
|
||||
"takenOnDevice": null,
|
||||
"exposureTime": null,
|
||||
"ISO": null,
|
||||
"megaPixels": null
|
||||
};
|
||||
|
||||
bool _isImage = false;
|
||||
int? _currentUserID;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
debugPrint('file_info_dialog initState');
|
||||
_currentUserID = Configuration.instance.getUserID();
|
||||
_isImage = widget.file.fileType == FileType.image ||
|
||||
widget.file.fileType == FileType.livePhoto;
|
||||
if (_isImage) {
|
||||
getExif(widget.file).then((exif) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exif = exif;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = widget.file;
|
||||
final fileIsBackedup = file.uploadedFileID == null ? false : true;
|
||||
final bool isFileOwner =
|
||||
file.ownerID == null || file.ownerID == _currentUserID;
|
||||
late Future<Set<int>> allCollectionIDsOfFile;
|
||||
//Typing this as Future<Set<T>> as it would be easier to implement showing multiple device folders for a file in the future
|
||||
final Future<Set<String>> allDeviceFoldersOfFile =
|
||||
Future.sync(() => {file.deviceFolder ?? ''});
|
||||
if (fileIsBackedup) {
|
||||
allCollectionIDsOfFile = FilesDB.instance.getAllCollectionIDsOfFile(
|
||||
file.uploadedFileID!,
|
||||
);
|
||||
}
|
||||
final dateTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
|
||||
final dateTimeForUpdationTime =
|
||||
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!);
|
||||
|
||||
if (_isImage && _exif != null) {
|
||||
_generateExifForDetails(_exif!);
|
||||
}
|
||||
final bool showExifListTile = _exifData["focalLength"] != null ||
|
||||
_exifData["fNumber"] != null ||
|
||||
_exifData["takenOnDevice"] != null ||
|
||||
_exifData["exposureTime"] != null ||
|
||||
_exifData["ISO"] != null;
|
||||
final bool showDimension =
|
||||
_exifData["resolution"] != null && _exifData["megaPixels"] != null;
|
||||
final listTiles = <Widget?>[
|
||||
!widget.file.isUploaded ||
|
||||
(!isFileOwner && (widget.file.caption?.isEmpty ?? true))
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: isFileOwner
|
||||
? FileCaptionWidget(file: widget.file)
|
||||
: FileCaptionReadyOnly(caption: widget.file.caption!),
|
||||
),
|
||||
ListTile(
|
||||
horizontalTitleGap: 2,
|
||||
leading: const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Icon(Icons.calendar_today_rounded),
|
||||
),
|
||||
title: Text(
|
||||
getFullDate(
|
||||
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
getTimeIn12hrFormat(dateTime) + " " + dateTime.timeZoneName,
|
||||
style: Theme.of(context).textTheme.bodyText2!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.defaultTextColor
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
trailing: (widget.file.ownerID == null ||
|
||||
widget.file.ownerID == _currentUserID) &&
|
||||
widget.file.uploadedFileID != null
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_showDateTimePicker(widget.file);
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
ListTile(
|
||||
horizontalTitleGap: 2,
|
||||
leading: _isImage
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Icon(
|
||||
Icons.image,
|
||||
),
|
||||
)
|
||||
: const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Icon(
|
||||
Icons.video_camera_back,
|
||||
size: 27,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
path.basenameWithoutExtension(file.displayName) +
|
||||
path.extension(file.displayName).toUpperCase(),
|
||||
),
|
||||
subtitle: Wrap(
|
||||
children: [
|
||||
showDimension
|
||||
? Text(
|
||||
"${_exifData["megaPixels"]}MP "
|
||||
"${_exifData["resolution"]} ",
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
_getFileSize(),
|
||||
(file.fileType == FileType.video) &&
|
||||
(file.localID != null || file.duration != 0)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: _getVideoDuration(),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
trailing: file.uploadedFileID == null || file.ownerID != _currentUserID
|
||||
? const SizedBox.shrink()
|
||||
: IconButton(
|
||||
onPressed: () async {
|
||||
await editFilename(context, file);
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
),
|
||||
),
|
||||
showExifListTile
|
||||
? ListTile(
|
||||
horizontalTitleGap: 2,
|
||||
leading: const Icon(Icons.camera_rounded),
|
||||
title: Text(_exifData["takenOnDevice"] ?? "--"),
|
||||
subtitle: Wrap(
|
||||
children: [
|
||||
_exifData["fNumber"] != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Text('ƒ/' + _exifData["fNumber"].toString()),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
_exifData["exposureTime"] != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Text(_exifData["exposureTime"]),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
_exifData["focalLength"] != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child:
|
||||
Text(_exifData["focalLength"].toString() + "mm"),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
_exifData["ISO"] != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Text("ISO" + _exifData["ISO"].toString()),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
SizedBox(
|
||||
height: 62,
|
||||
child: ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: fileIsBackedup
|
||||
? CollectionsListOfFileWidget(
|
||||
allCollectionIDsOfFile,
|
||||
_currentUserID!,
|
||||
)
|
||||
: DeviceFoldersListOfFileWidget(allDeviceFoldersOfFile),
|
||||
),
|
||||
),
|
||||
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,
|
||||
leading: const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Icon(Icons.cloud_upload_outlined),
|
||||
),
|
||||
title: Text(
|
||||
getFullDate(
|
||||
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
getTimeIn12hrFormat(dateTimeForUpdationTime) +
|
||||
" " +
|
||||
dateTimeForUpdationTime.timeZoneName,
|
||||
style: Theme.of(context).textTheme.bodyText2!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.defaultTextColor
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
ListTile(
|
||||
horizontalTitleGap: 2,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: locationService.getLocationsByFileID(file.generatedID!).isEmpty
|
||||
? const Icon(Icons.add_location_alt_rounded)
|
||||
: const Icon(Icons.location_on_rounded),
|
||||
),
|
||||
title: Text(
|
||||
locationService.getLocationsByFileID(file.generatedID!).isEmpty
|
||||
? "Add Location"
|
||||
: "Locations",
|
||||
),
|
||||
subtitle:
|
||||
locationService.getLocationsByFileID(file.generatedID!).isEmpty
|
||||
? Text(
|
||||
"group nearby photos",
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.defaultTextColor
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
)
|
||||
: locationChipList(
|
||||
file.generatedID!,
|
||||
context,
|
||||
),
|
||||
trailing:
|
||||
locationService.getLocationsByFileID(file.generatedID!).isEmpty
|
||||
? IconButton(
|
||||
onPressed: () async {
|
||||
unawaited(
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return LocationsList(
|
||||
state: 1,
|
||||
fileId: file.generatedID,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_forward_ios),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
_isImage ? RawExifListTileWidget(_exif, widget.file) : null,
|
||||
];
|
||||
|
||||
listTiles.removeWhere(
|
||||
(element) => element == null,
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Scrollbar(
|
||||
thickness: 4,
|
||||
radius: const Radius.circular(2),
|
||||
thumbVisibility: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CustomScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
slivers: <Widget>[
|
||||
TitleBarWidget(
|
||||
isFlexibleSpaceDisabled: true,
|
||||
title: "Details",
|
||||
isOnTopOfScreen: false,
|
||||
backgroundColor: getEnteColorScheme(context).backgroundElevated,
|
||||
leading: IconButtonWidget(
|
||||
icon: Icons.close_outlined,
|
||||
iconButtonType: IconButtonType.primary,
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: addedBy(widget.file)),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index.isOdd) {
|
||||
return index == 1
|
||||
? const SizedBox.shrink()
|
||||
: const DividerWidget(
|
||||
dividerType: DividerType.menu,
|
||||
);
|
||||
} else {
|
||||
return listTiles[index ~/ 2];
|
||||
}
|
||||
},
|
||||
childCount: (listTiles.length * 2) - 1,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget addedBy(File file) {
|
||||
if (file.uploadedFileID == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
String? addedBy;
|
||||
if (file.ownerID == _currentUserID) {
|
||||
if (file.pubMagicMetadata!.uploaderName != null) {
|
||||
addedBy = file.pubMagicMetadata!.uploaderName;
|
||||
}
|
||||
} else {
|
||||
final fileOwner = CollectionsService.instance
|
||||
.getFileOwner(file.ownerID!, file.collectionID);
|
||||
addedBy = fileOwner.email;
|
||||
}
|
||||
if (addedBy == null || addedBy.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final enteTheme = Theme.of(context).colorScheme.enteTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 16),
|
||||
child: Text(
|
||||
"Added by $addedBy",
|
||||
style: enteTheme.textTheme.mini
|
||||
.copyWith(color: enteTheme.colorScheme.textMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_generateExifForDetails(Map<String, IfdTag> exif) {
|
||||
if (exif["EXIF FocalLength"] != null) {
|
||||
_exifData["focalLength"] =
|
||||
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
|
||||
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
|
||||
.denominator;
|
||||
}
|
||||
|
||||
if (exif["EXIF FNumber"] != null) {
|
||||
_exifData["fNumber"] =
|
||||
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
|
||||
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
|
||||
}
|
||||
final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
|
||||
final imageLength = exif["EXIF ExifImageLength"] ??
|
||||
exif["Image "
|
||||
"ImageLength"];
|
||||
if (imageWidth != null && imageLength != null) {
|
||||
_exifData["resolution"] = '$imageWidth x $imageLength';
|
||||
_exifData['megaPixels'] =
|
||||
((imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
|
||||
1000000)
|
||||
.toStringAsFixed(1);
|
||||
} else {
|
||||
debugPrint("No image width/height");
|
||||
}
|
||||
if (exif["Image Make"] != null && exif["Image Model"] != null) {
|
||||
_exifData["takenOnDevice"] =
|
||||
exif["Image Make"].toString() + " " + exif["Image Model"].toString();
|
||||
}
|
||||
|
||||
if (exif["EXIF ExposureTime"] != null) {
|
||||
_exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
|
||||
}
|
||||
if (exif["EXIF ISOSpeedRatings"] != null) {
|
||||
_exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getFileSize() {
|
||||
Future<int> fileSizeFuture;
|
||||
if (widget.file.fileSize != null) {
|
||||
fileSizeFuture = Future.value(widget.file.fileSize);
|
||||
} else {
|
||||
fileSizeFuture = getFile(widget.file).then((f) => f!.length());
|
||||
}
|
||||
return FutureBuilder<int>(
|
||||
future: fileSizeFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
(snapshot.data! / (1024 * 1024)).toStringAsFixed(2) + " MB",
|
||||
);
|
||||
} else {
|
||||
return Center(
|
||||
child: SizedBox.fromSize(
|
||||
size: const Size.square(24),
|
||||
child: const CupertinoActivityIndicator(
|
||||
radius: 8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getVideoDuration() {
|
||||
if (widget.file.duration != 0) {
|
||||
return Text(
|
||||
secondsToHHMMSS(widget.file.duration!),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<AssetEntity?>(
|
||||
future: widget.file.getAsset,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data!.videoDuration.toString().split(".")[0],
|
||||
);
|
||||
} else {
|
||||
return Center(
|
||||
child: SizedBox.fromSize(
|
||||
size: const Size.square(24),
|
||||
child: const CupertinoActivityIndicator(
|
||||
radius: 8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDateTimePicker(File file) async {
|
||||
final dateResult = await DatePicker.showDatePicker(
|
||||
context,
|
||||
minTime: DateTime(1800, 1, 1),
|
||||
maxTime: DateTime.now(),
|
||||
currentTime: DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
|
||||
locale: LocaleType.en,
|
||||
theme: Theme.of(context).colorScheme.dateTimePickertheme,
|
||||
);
|
||||
if (dateResult == null) {
|
||||
return;
|
||||
}
|
||||
final dateWithTimeResult = await DatePicker.showTime12hPicker(
|
||||
context,
|
||||
showTitleActions: true,
|
||||
currentTime: dateResult,
|
||||
locale: LocaleType.en,
|
||||
theme: Theme.of(context).colorScheme.dateTimePickertheme,
|
||||
);
|
||||
if (dateWithTimeResult != null) {
|
||||
if (await editTime(
|
||||
context,
|
||||
List.of([widget.file]),
|
||||
dateWithTimeResult.microsecondsSinceEpoch,
|
||||
)) {
|
||||
widget.file.creationTime = dateWithTimeResult.microsecondsSinceEpoch;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/ente_theme_data.dart";
|
||||
import "package:photos/models/file.dart";
|
||||
import "package:photos/services/object_detection/object_detection_service.dart";
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import "package:photos/utils/thumbnail_util.dart";
|
||||
|
||||
class ObjectTagsWidget extends StatelessWidget {
|
||||
final File file;
|
||||
|
||||
const ObjectTagsWidget(this.file, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<String>>(
|
||||
future: getThumbnail(file).then((data) {
|
||||
return ObjectDetectionService.instance.predict(data!);
|
||||
}),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final List<String> tags = snapshot.data!;
|
||||
if (tags.isEmpty) {
|
||||
return const ObjectTagWidget("No Results");
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: tags.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return ObjectTagWidget(tags[index]);
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
Logger("ObjectTagsWidget").severe(snapshot.error);
|
||||
return const Icon(Icons.error);
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ObjectTagWidget extends StatelessWidget {
|
||||
final String name;
|
||||
const ObjectTagWidget(this.name, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: 10,
|
||||
bottom: 18,
|
||||
right: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.inverseBackgroundColor
|
||||
.withOpacity(0.025),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
name!,
|
||||
style: Theme.of(context).textTheme.subtitle2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
import 'package:exif/exif.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import "package:photos/models/file.dart";
|
||||
import 'package:photos/ui/viewer/file/exif_info_dialog.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
||||
enum Status {
|
||||
loading,
|
||||
exifIsAvailable,
|
||||
noExif,
|
||||
}
|
||||
|
||||
class RawExifListTileWidget extends StatelessWidget {
|
||||
final File file;
|
||||
final Map<String, IfdTag>? exif;
|
||||
const RawExifListTileWidget(this.exif, this.file, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Status exifStatus = Status.loading;
|
||||
if (exif == null) {
|
||||
exifStatus = Status.loading;
|
||||
} else if (exif!.isNotEmpty) {
|
||||
exifStatus = Status.exifIsAvailable;
|
||||
} else {
|
||||
exifStatus = Status.noExif;
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: exifStatus == Status.exifIsAvailable
|
||||
? () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return ExifInfoDialog(file);
|
||||
},
|
||||
barrierColor: Colors.black87,
|
||||
);
|
||||
}
|
||||
: exifStatus == Status.noExif
|
||||
? () {
|
||||
showShortToast(context, "This image has no exif data");
|
||||
}
|
||||
: null,
|
||||
child: ListTile(
|
||||
horizontalTitleGap: 2,
|
||||
leading: const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Icon(Icons.feed_outlined),
|
||||
),
|
||||
title: const Text("EXIF"),
|
||||
subtitle: Text(
|
||||
exifStatus == Status.loading
|
||||
? "Loading EXIF data.."
|
||||
: exifStatus == Status.exifIsAvailable
|
||||
? "View all EXIF data"
|
||||
: "No EXIF data",
|
||||
style: Theme.of(context).textTheme.bodyText2!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.defaultTextColor
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
37
lib/ui/viewer/file_details/added_by_widget.dart
Normal file
37
lib/ui/viewer/file_details/added_by_widget.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:photos/models/file.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
|
||||
class AddedByWidget extends StatelessWidget {
|
||||
final File file;
|
||||
final int currentUserID;
|
||||
const AddedByWidget(this.file, this.currentUserID, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (file.uploadedFileID == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
String? addedBy;
|
||||
if (file.ownerID == currentUserID) {
|
||||
if (file.pubMagicMetadata!.uploaderName != null) {
|
||||
addedBy = file.pubMagicMetadata!.uploaderName;
|
||||
}
|
||||
} else {
|
||||
final fileOwner = CollectionsService.instance
|
||||
.getFileOwner(file.ownerID!, file.collectionID);
|
||||
addedBy = fileOwner.email;
|
||||
}
|
||||
if (addedBy == null || addedBy.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 16),
|
||||
child: Text(
|
||||
"Added by $addedBy",
|
||||
style: getEnteTextTheme(context).miniMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
109
lib/ui/viewer/file_details/albums_item_widget.dart
Normal file
109
lib/ui/viewer/file_details/albums_item_widget.dart
Normal file
|
@ -0,0 +1,109 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/db/files_db.dart";
|
||||
import "package:photos/models/collection.dart";
|
||||
import "package:photos/models/collection_items.dart";
|
||||
import "package:photos/models/file.dart";
|
||||
import "package:photos/models/gallery_type.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/ui/components/buttons/chip_button_widget.dart";
|
||||
import "package:photos/ui/components/info_item_widget.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
|
||||
class AlbumsItemWidget extends StatelessWidget {
|
||||
final File file;
|
||||
final int currentUserID;
|
||||
const AlbumsItemWidget(
|
||||
this.file,
|
||||
this.currentUserID, {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fileIsBackedup = file.uploadedFileID == null ? false : true;
|
||||
late Future<Set<int>> allCollectionIDsOfFile;
|
||||
//Typing this as Future<Set<T>> as it would be easier to implement showing multiple device folders for a file in the future
|
||||
final Future<Set<String>> allDeviceFoldersOfFile =
|
||||
Future.sync(() => {file.deviceFolder ?? ''});
|
||||
if (fileIsBackedup) {
|
||||
allCollectionIDsOfFile = FilesDB.instance.getAllCollectionIDsOfFile(
|
||||
file.uploadedFileID!,
|
||||
);
|
||||
}
|
||||
return InfoItemWidget(
|
||||
key: const ValueKey("Albums"),
|
||||
leadingIcon: Icons.folder_outlined,
|
||||
title: "Albums",
|
||||
subtitleSection: fileIsBackedup
|
||||
? _collectionsListOfFile(
|
||||
context,
|
||||
allCollectionIDsOfFile,
|
||||
currentUserID,
|
||||
)
|
||||
: _deviceFoldersListOfFile(allDeviceFoldersOfFile),
|
||||
hasChipButtons: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChipButtonWidget>> _deviceFoldersListOfFile(
|
||||
Future<Set<String>> allDeviceFoldersOfFile,
|
||||
) async {
|
||||
try {
|
||||
final chipButtons = <ChipButtonWidget>[];
|
||||
final List<String> deviceFolders =
|
||||
(await allDeviceFoldersOfFile).toList();
|
||||
for (var deviceFolder in deviceFolders) {
|
||||
chipButtons.add(
|
||||
ChipButtonWidget(
|
||||
deviceFolder,
|
||||
),
|
||||
);
|
||||
}
|
||||
return chipButtons;
|
||||
} catch (e, s) {
|
||||
Logger("AlbumsItemWidget").info(e, s);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ChipButtonWidget>> _collectionsListOfFile(
|
||||
BuildContext context,
|
||||
Future<Set<int>> allCollectionIDsOfFile,
|
||||
int currentUserID,
|
||||
) async {
|
||||
try {
|
||||
final chipButtons = <ChipButtonWidget>[];
|
||||
final Set<int> collectionIDs = await allCollectionIDsOfFile;
|
||||
final collections = <Collection>[];
|
||||
for (var collectionID in collectionIDs) {
|
||||
final c = CollectionsService.instance.getCollectionByID(collectionID);
|
||||
collections.add(c!);
|
||||
chipButtons.add(
|
||||
ChipButtonWidget(
|
||||
c.isHidden() ? "Hidden" : c.name,
|
||||
onTap: () {
|
||||
if (c.isHidden()) {
|
||||
return;
|
||||
}
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionPage(
|
||||
CollectionWithThumbnail(c, null),
|
||||
appBarType: c.isOwner(currentUserID)
|
||||
? GalleryType.ownedCollection
|
||||
: GalleryType.sharedCollection,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return chipButtons;
|
||||
} catch (e, s) {
|
||||
Logger("AlbumsItemWidget").info(e, s);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
31
lib/ui/viewer/file_details/backed_up_time_item_widget.dart
Normal file
31
lib/ui/viewer/file_details/backed_up_time_item_widget.dart
Normal file
|
@ -0,0 +1,31 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:photos/models/file.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/components/info_item_widget.dart";
|
||||
import "package:photos/utils/date_time_util.dart";
|
||||
|
||||
class BackedUpTimeItemWidget extends StatelessWidget {
|
||||
final File file;
|
||||
const BackedUpTimeItemWidget(this.file, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateTimeForUpdationTime =
|
||||
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!);
|
||||
return InfoItemWidget(
|
||||
key: const ValueKey("Backedup date"),
|
||||
leadingIcon: Icons.backup_outlined,
|
||||
title: getFullDate(
|
||||
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!),
|
||||
),
|
||||
subtitleSection: Future.value([
|
||||
Text(
|
||||
getTimeIn12hrFormat(dateTimeForUpdationTime) +
|
||||
" " +
|
||||
dateTimeForUpdationTime.timeZoneName,
|
||||
style: getEnteTextTheme(context).smallMuted,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue