Compare commits
8 Commits
it_auth
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e783c5d9b | ||
|
|
d45458f6fd | ||
|
|
65a7a16298 | ||
|
|
9251e4f5b6 | ||
|
|
c4bc6abf83 | ||
|
|
b5aa05cc1b | ||
|
|
cd865992f2 | ||
|
|
370c0ab54a |
2
mobile/apps/auth/.gitignore
vendored
2
mobile/apps/auth/.gitignore
vendored
@@ -33,8 +33,6 @@
|
||||
.pub/
|
||||
/build/
|
||||
macos/build/
|
||||
.gradle/
|
||||
settings.local.json
|
||||
|
||||
# Web related
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
import 'package:ente_auth/app/view/app.dart';
|
||||
import 'package:ente_auth/bootstrap.dart';
|
||||
import 'package:ente_auth/main.dart';
|
||||
import 'package:ente_auth/onboarding/view/common/add_chip.dart';
|
||||
import 'package:ente_auth/services/update_service.dart';
|
||||
import 'package:ente_lock_screen/local_authentication_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Authentication Flow Integration Test with Persistence', () {
|
||||
testWidgets(
|
||||
'Complete auth flow: Use without backup -> Enter setup key -> Verify entry persists after restart',
|
||||
(WidgetTester tester) async {
|
||||
// Bootstrap the app
|
||||
await bootstrap(App.new);
|
||||
await init(false, via: 'integrationTest');
|
||||
await UpdateService.instance.init();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 1: Click on "Use without backup" option
|
||||
final useOfflineText = find.text('Use without backups');
|
||||
expect(
|
||||
useOfflineText,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Use without backups" button not found on initial screen. Check if app loaded correctly or text changed.',
|
||||
);
|
||||
await tester.tap(useOfflineText);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 2: Click OK button on the warning dialog
|
||||
final okButton = find.text('Ok');
|
||||
expect(
|
||||
okButton,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Ok" button not found in warning dialog. Check if dialog appeared or button text changed.',
|
||||
);
|
||||
await tester.tap(okButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for navigation to complete
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Step 3: Navigate to manual entry screen
|
||||
bool foundManualEntry = false;
|
||||
|
||||
// Try FloatingActionButton approach first
|
||||
final fabFinder = find.byType(FloatingActionButton);
|
||||
if (fabFinder.evaluate().isNotEmpty) {
|
||||
await tester.tap(fabFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final manualEntryFinder = find.text('Enter a setup key');
|
||||
if (manualEntryFinder.evaluate().isNotEmpty) {
|
||||
await tester.tap(manualEntryFinder);
|
||||
await tester.pumpAndSettle();
|
||||
foundManualEntry = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative approaches if FAB didn't work
|
||||
if (!foundManualEntry) {
|
||||
final alternatives = [
|
||||
'Enter details manually',
|
||||
'Enter a setup key',
|
||||
];
|
||||
|
||||
for (final text in alternatives) {
|
||||
final finder = find.text(text);
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
await tester.tap(finder.first);
|
||||
await tester.pumpAndSettle();
|
||||
foundManualEntry = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
foundManualEntry,
|
||||
isTrue,
|
||||
reason:
|
||||
'ERROR: Could not find manual entry option. Tried FAB + "Enter details manually", "Enter a setup key", etc. Check UI navigation.',
|
||||
);
|
||||
|
||||
// Step 4: Fill in the form with test data
|
||||
final textFields = find.byType(TextFormField);
|
||||
expect(
|
||||
textFields.evaluate().length,
|
||||
greaterThanOrEqualTo(3),
|
||||
reason:
|
||||
'ERROR: Expected at least 3 text fields (issuer, secret, account) but found ${textFields.evaluate().length}. Check manual entry form.',
|
||||
);
|
||||
|
||||
// Fill issuer field
|
||||
await tester.tap(textFields.first);
|
||||
await tester.enterText(textFields.first, 'testIssuer');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill secret field
|
||||
await tester.tap(textFields.at(1));
|
||||
await tester.enterText(
|
||||
textFields.at(1),
|
||||
'JBSWY3DPEHPK3PXP',
|
||||
); // Valid base32 secret
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill account field
|
||||
await tester.tap(textFields.at(2));
|
||||
await tester.enterText(textFields.at(2), 'testAccount');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 5: Save the entry
|
||||
final saveButton = find.text('Save');
|
||||
expect(
|
||||
saveButton,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Save" button not found on manual entry form. Check if button text changed or form layout changed.',
|
||||
);
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 6: Verify entry was created successfully
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Check if coach mark overlay is present and dismiss it
|
||||
final coachMarkOverlay = find.text('Ok');
|
||||
if (coachMarkOverlay.evaluate().isNotEmpty) {
|
||||
print('🎯 Dismissing coach mark overlay...');
|
||||
await tester.tap(coachMarkOverlay);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Look for the created entry
|
||||
final issuerEntryFinder = find.textContaining('testIssuer');
|
||||
expect(
|
||||
issuerEntryFinder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason:
|
||||
'ERROR: testIssuer entry not found after saving. Entry creation may have failed or navigation issue occurred.',
|
||||
);
|
||||
|
||||
final accountEntryFinder = find.textContaining('testAccount');
|
||||
expect(
|
||||
accountEntryFinder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason:
|
||||
'ERROR: testAccount not found after saving. Account field may not have been saved properly.',
|
||||
);
|
||||
print('✅ Step 1 completed: Entry created successfully');
|
||||
print('- testIssuer entry is visible');
|
||||
print('- testAccount is visible');
|
||||
|
||||
// warning about clearing
|
||||
|
||||
// Step 8: Add second code entry
|
||||
print('🔄 Adding second code entry...');
|
||||
|
||||
// Wait a moment before adding second entry
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Click FAB to add second entry
|
||||
final fabFinder2 = find.byType(FloatingActionButton);
|
||||
expect(
|
||||
fabFinder2,
|
||||
findsOneWidget,
|
||||
reason: 'FAB not found for second entry',
|
||||
);
|
||||
await tester.tap(fabFinder2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Click "Enter details manually" (coach mark won't show second time)
|
||||
final manualEntryFinder = find.text('Enter details manually');
|
||||
expect(
|
||||
manualEntryFinder,
|
||||
findsOneWidget,
|
||||
reason: 'Manual entry option not found',
|
||||
);
|
||||
await tester.tap(manualEntryFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill second entry form
|
||||
final textFields2 = find.byType(TextFormField);
|
||||
expect(textFields2.evaluate().length, greaterThanOrEqualTo(3));
|
||||
|
||||
// Fill second issuer field
|
||||
await tester.tap(textFields2.first);
|
||||
await tester.enterText(textFields2.first, 'testIssuer2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer field was filled
|
||||
final issuerField = tester.widget<TextFormField>(textFields2.first);
|
||||
print(
|
||||
'✓ Issuer field controller text: "${issuerField.controller?.text ?? "null"}"');
|
||||
|
||||
// Fill second secret field
|
||||
await tester.tap(textFields2.at(1));
|
||||
await tester.enterText(textFields2.at(1), 'JBSWY3DPEHPK3PXP');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify secret field was filled
|
||||
final secretField = tester.widget<TextFormField>(textFields2.at(1));
|
||||
print(
|
||||
'✓ Secret field controller text: "${secretField.controller?.text ?? "null"}"');
|
||||
|
||||
// Fill second account field
|
||||
await tester.tap(textFields2.at(2));
|
||||
await tester.enterText(textFields2.at(2), 'testAccount2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Save second entry
|
||||
final saveButton2 = find.text('Save');
|
||||
await tester.tap(saveButton2);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Verify both entries exist
|
||||
final issuer1Finder = find.textContaining('testIssuer');
|
||||
final issuer2Finder = find.textContaining('testIssuer2');
|
||||
final account1Finder = find.textContaining('testAccount');
|
||||
final account2Finder = find.textContaining('testAccount2');
|
||||
|
||||
expect(issuer1Finder, findsAtLeastNWidgets(1),
|
||||
reason: 'First issuer not found');
|
||||
expect(issuer2Finder, findsAtLeastNWidgets(1),
|
||||
reason: 'Second issuer not found');
|
||||
expect(
|
||||
account1Finder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason: 'First account not found',
|
||||
);
|
||||
expect(
|
||||
account2Finder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason: 'Second account not found',
|
||||
);
|
||||
|
||||
print('✅ Step 2 completed: Both entries created successfully');
|
||||
print('- testIssuer and testIssuer2 entries are visible');
|
||||
print('- testAccount and testAccount2 are visible');
|
||||
|
||||
// Step 9: Test search functionality
|
||||
print('🔍 Testing search functionality...');
|
||||
|
||||
// Click on search icon to activate search
|
||||
final searchIcon = find.byIcon(Icons.search);
|
||||
expect(searchIcon, findsOneWidget, reason: 'Search icon not found');
|
||||
await tester.tap(searchIcon);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find the search text field
|
||||
final searchField = find.byType(TextField);
|
||||
expect(searchField, findsOneWidget,
|
||||
reason: 'Search text field not found');
|
||||
|
||||
// Enter search term "issuer2"
|
||||
await tester.tap(searchField);
|
||||
await tester.enterText(searchField, 'issuer2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify only one result is shown (testIssuer2)
|
||||
final searchResults = find.textContaining('testIssuer');
|
||||
final issuer2Results = find.textContaining('testIssuer2');
|
||||
|
||||
// Should find testIssuer2 but not testIssuer when searching for "issuer2"
|
||||
expect(issuer2Results, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not found in search results');
|
||||
|
||||
// Verify total results - should only show the matching entry
|
||||
final allVisibleIssuers = find.textContaining('testIssuer');
|
||||
expect(allVisibleIssuers.evaluate().length, equals(1),
|
||||
reason: 'Search should show only one result for "issuer2"');
|
||||
|
||||
print('✅ Search results verified: only testIssuer2 is visible');
|
||||
|
||||
// Clear search bar
|
||||
final clearIcon = find.byIcon(Icons.clear);
|
||||
expect(clearIcon, findsOneWidget,
|
||||
reason: 'Clear search icon not found');
|
||||
await tester.tap(clearIcon);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are visible again after clearing search
|
||||
final allIssuer1 = find.textContaining('testIssuer');
|
||||
final allIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allIssuer1, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer not visible after clearing search');
|
||||
expect(allIssuer2, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not visible after clearing search');
|
||||
|
||||
print('✅ Search cleared: both entries visible again');
|
||||
print('✅ Step 3 completed: Search functionality working correctly');
|
||||
|
||||
// Step 10: Long press on issuer2 to edit and add tags
|
||||
print('🏷️ Testing tag functionality...');
|
||||
|
||||
// Long press on testIssuer2 entry to bring up edit menu
|
||||
final issuer2Entry = find.textContaining('testIssuer2');
|
||||
expect(issuer2Entry, findsOneWidget,
|
||||
reason: 'testIssuer2 entry not found for long press');
|
||||
await tester.longPress(issuer2Entry);
|
||||
await tester.pumpAndSettle();
|
||||
LocalAuthenticationService.instance.lastAuthTime = DateTime.now()
|
||||
.add(const Duration(minutes: 10))
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
// Look for edit option and tap it
|
||||
final editOption = find.text('Edit');
|
||||
expect(editOption, findsOneWidget, reason: 'Edit option not found');
|
||||
await tester.tap(editOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for edit page to load
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Look for AddChip widget to add first tag
|
||||
final addChip = find.byType(AddChip);
|
||||
expect(addChip, findsOneWidget, reason: 'AddChip widget not found');
|
||||
await tester.tap(addChip);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter first tag name "tag1"
|
||||
final tagInputField = find.byType(TextField).last;
|
||||
await tester.tap(tagInputField);
|
||||
await tester.enterText(tagInputField, 'tag1');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap create/save button for first tag
|
||||
final createButton = find.text('Create');
|
||||
expect(createButton, findsOneWidget,
|
||||
reason: 'Create button not found for first tag');
|
||||
await tester.tap(createButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Add second tag
|
||||
final addChip2 = find.byType(AddChip);
|
||||
await tester.tap(addChip2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter second tag name "tag2"
|
||||
final tagInputField2 = find.byType(TextField).last;
|
||||
await tester.tap(tagInputField2);
|
||||
await tester.enterText(tagInputField2, 'tag2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap create button for second tag
|
||||
final createButton2 = find.text('Create');
|
||||
await tester.tap(createButton2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify tags are selected/visible
|
||||
final tag1Chip = find.text('tag1');
|
||||
final tag2Chip = find.text('tag2');
|
||||
expect(tag1Chip, findsOneWidget,
|
||||
reason: 'tag1 not found after creation');
|
||||
expect(tag2Chip, findsOneWidget,
|
||||
reason: 'tag2 not found after creation');
|
||||
|
||||
print('✅ Tags created: tag1 and tag2 are visible');
|
||||
|
||||
// Save the edited entry
|
||||
final saveEditButton = find.text('Save');
|
||||
expect(saveEditButton, findsOneWidget,
|
||||
reason: 'Save button not found on edit page');
|
||||
await tester.tap(saveEditButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for navigation back to home
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
print('✅ Entry saved with tags');
|
||||
|
||||
// Step 11: Test tag filtering functionality
|
||||
print('🏷️ Testing tag filtering...');
|
||||
|
||||
// Click on tag1 to filter entries
|
||||
final tag1Filter = find.textContaining('tag1');
|
||||
if (tag1Filter.evaluate().isNotEmpty) {
|
||||
await tester.tap(tag1Filter.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify only testIssuer2 is visible (the one with tag1)
|
||||
final filteredIssuer2 = find.textContaining('testIssuer2');
|
||||
final filteredIssuer1 = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
|
||||
expect(filteredIssuer2, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not visible when filtering by tag1');
|
||||
expect(filteredIssuer1, equals(0),
|
||||
reason: 'testIssuer should not be visible when filtering by tag1');
|
||||
|
||||
print('✅ Tag1 filtering verified: only testIssuer2 is visible');
|
||||
|
||||
// Click "All" to clear tag filter
|
||||
final allFilter = find.text('All');
|
||||
if (allFilter.evaluate().isNotEmpty) {
|
||||
await tester.tap(allFilter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are visible again
|
||||
final allEntriesIssuer1 = find.textContaining('testIssuer');
|
||||
final allEntriesIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allEntriesIssuer1, findsAtLeastNWidgets(1));
|
||||
expect(allEntriesIssuer2, findsAtLeastNWidgets(1));
|
||||
|
||||
print('✅ Tag filter cleared: both entries visible again');
|
||||
}
|
||||
}
|
||||
|
||||
print('✅ Step 4 completed: Tag functionality working correctly');
|
||||
|
||||
// Step 12: Test trash functionality
|
||||
print('🗑️ Testing trash functionality...');
|
||||
|
||||
// Long press on testIssuer2 entry to bring up context menu
|
||||
final issuer2EntryForTrash = find.textContaining('testIssuer2').first;
|
||||
await tester.longPress(issuer2EntryForTrash);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Look for trash/delete option and tap it
|
||||
final trashOption = find.text('Trash');
|
||||
if (trashOption.evaluate().isEmpty) {
|
||||
// Try alternative delete options
|
||||
final deleteOption = find.text('Delete');
|
||||
expect(deleteOption, findsOneWidget, reason: 'Delete/Trash option not found');
|
||||
await tester.tap(deleteOption);
|
||||
} else {
|
||||
await tester.tap(trashOption);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm deletion if dialog appears
|
||||
final confirmButtons = [
|
||||
find.text('Yes'),
|
||||
find.text('OK'),
|
||||
find.text('Confirm'),
|
||||
find.text('Delete'),
|
||||
find.text('Trash'),
|
||||
];
|
||||
|
||||
for (final button in confirmButtons) {
|
||||
if (button.evaluate().isNotEmpty) {
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
print('✅ Issuer2 entry trashed');
|
||||
|
||||
// Step 13: Verify tags are no longer visible and trash tag appears
|
||||
print('🏷️ Verifying tag visibility after trash...');
|
||||
|
||||
// Wait for UI to update
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Verify tag1 and tag2 are no longer visible (since issuer2 was the only entry with these tags)
|
||||
final tag1Visible = find.textContaining('tag1');
|
||||
final tag2Visible = find.textContaining('tag2');
|
||||
|
||||
// Tags should not be visible anymore since the only entry with these tags was trashed
|
||||
expect(tag1Visible.evaluate().isEmpty, isTrue, reason: 'tag1 should not be visible after trashing issuer2');
|
||||
expect(tag2Visible.evaluate().isEmpty, isTrue, reason: 'tag2 should not be visible after trashing issuer2');
|
||||
|
||||
// Verify trash tag is now visible
|
||||
final trashTag = find.text('Trash');
|
||||
expect(trashTag, findsOneWidget, reason: 'Trash tag not visible after trashing entry');
|
||||
|
||||
print('✅ Tags hidden and Trash tag visible');
|
||||
|
||||
// Step 14: Test trash filtering
|
||||
print('🗑️ Testing trash tag filtering...');
|
||||
|
||||
// Click on Trash tag to show trashed items
|
||||
await tester.tap(trashTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer2 is visible in trash
|
||||
final trashedIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(trashedIssuer2, findsOneWidget, reason: 'testIssuer2 not visible in trash');
|
||||
|
||||
// Verify issuer1 is not visible (should be filtered out)
|
||||
final issuer1InTrash = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
expect(issuer1InTrash, equals(0), reason: 'testIssuer should not be visible in trash filter');
|
||||
|
||||
print('✅ Trash filtering working: only trashed items visible');
|
||||
|
||||
// Step 15: Test All filter (should not show trashed items)
|
||||
print('📋 Testing All filter excludes trash...');
|
||||
|
||||
// Click on "All" to show all non-trashed items
|
||||
final allTag = find.text('All');
|
||||
await tester.tap(allTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer1 is visible
|
||||
final allFilterIssuer1 = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
expect(allFilterIssuer1, greaterThan(0), reason: 'testIssuer should be visible in All filter');
|
||||
|
||||
// Verify issuer2 is NOT visible in All
|
||||
final allFilterIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allFilterIssuer2.evaluate().isEmpty, isTrue, reason: 'testIssuer2 should not be visible in All filter');
|
||||
|
||||
print('✅ All filter working: trashed items excluded');
|
||||
|
||||
// Step 16: Test restore functionality
|
||||
print('♻️ Testing restore functionality...');
|
||||
|
||||
// Go back to trash view
|
||||
await tester.tap(trashTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Long press on trashed issuer2 entry
|
||||
final trashedEntryForRestore = find.textContaining('testIssuer2');
|
||||
await tester.longPress(trashedEntryForRestore);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Look for restore option
|
||||
final restoreOption = find.text('Restore');
|
||||
expect(restoreOption, findsOneWidget, reason: 'Restore option not found');
|
||||
await tester.tap(restoreOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
print('✅ Restore option tapped');
|
||||
|
||||
// Step 17: Verify restoration worked
|
||||
print('✅ Verifying restoration...');
|
||||
|
||||
// Wait for restoration to complete
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Go to All view to check if issuer2 is restored
|
||||
await tester.tap(allTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are now visible in All
|
||||
final restoredIssuer1 = find.textContaining('testIssuer');
|
||||
final restoredIssuer2 = find.textContaining('testIssuer2');
|
||||
|
||||
expect(restoredIssuer1, findsAtLeastNWidgets(1), reason: 'testIssuer not visible after restore');
|
||||
expect(restoredIssuer2, findsAtLeastNWidgets(1), reason: 'testIssuer2 not visible after restore');
|
||||
|
||||
// Verify tags are visible again
|
||||
final restoredTag1 = find.textContaining('tag1');
|
||||
final restoredTag2 = find.textContaining('tag2');
|
||||
|
||||
if (restoredTag1.evaluate().isNotEmpty && restoredTag2.evaluate().isNotEmpty) {
|
||||
print('✅ Tags restored and visible again');
|
||||
}
|
||||
|
||||
print('✅ Step 5 completed: Trash and restore functionality working correctly');
|
||||
|
||||
print('✅ Integration test completed successfully!');
|
||||
print('- Both entries created and verified');
|
||||
print('- Search functionality tested and working');
|
||||
print('- Tag functionality tested and working');
|
||||
print('- Trash functionality tested and working');
|
||||
print('- Restore functionality tested and working');
|
||||
print('- Multiple TOTP codes are being generated');
|
||||
print('- Data persistence is working correctly');
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,8 +68,6 @@ PODS:
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -152,7 +150,6 @@ DEPENDENCIES:
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
@@ -213,8 +210,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
move_to_background:
|
||||
@@ -265,7 +260,6 @@ SPEC CHECKSUMS:
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
local_auth_darwin: fa4b06454df7df8e97c18d7ee55151c57e7af0de
|
||||
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
|
||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
||||
|
||||
@@ -99,7 +99,7 @@ Future<void> _runInForeground() async {
|
||||
return await _runWithLogs(() async {
|
||||
_logger.info("Starting app in foreground");
|
||||
try {
|
||||
await init(false, via: 'mainMethod');
|
||||
await _init(false, via: 'mainMethod');
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to init", e, s);
|
||||
rethrow;
|
||||
@@ -160,7 +160,7 @@ void _registerWindowsProtocol() {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init(bool bool, {String? via}) async {
|
||||
Future<void> _init(bool bool, {String? via}) async {
|
||||
_registerWindowsProtocol();
|
||||
await CryptoUtil.init();
|
||||
|
||||
|
||||
@@ -605,11 +605,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_email_sender:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -858,11 +853,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -975,11 +965,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1373,14 +1358,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.8"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.3"
|
||||
protobuf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1763,14 +1740,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2003,14 +1972,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -138,8 +138,6 @@ dev_dependencies:
|
||||
build_runner: ^2.1.11
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
json_serializable: ^6.2.0
|
||||
lints: ^5.1.1
|
||||
mocktail: ^1.0.3
|
||||
|
||||
@@ -66,7 +66,6 @@ class UploadLocksDB {
|
||||
|
||||
static final migrationScripts = [
|
||||
..._createTrackUploadsTable(),
|
||||
..._createStreamQueueTable(),
|
||||
];
|
||||
|
||||
final dbConfig = MigrationConfig(
|
||||
@@ -142,11 +141,6 @@ class UploadLocksDB {
|
||||
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
''',
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> _createStreamQueueTable() {
|
||||
return [
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
|
||||
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
|
||||
@@ -158,7 +152,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.delete(_uploadLocksTable.table);
|
||||
await db.delete(_trackUploadTable.table);
|
||||
await db.delete(_partsTable.table);
|
||||
@@ -166,7 +160,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> acquireLock(String id, String owner, int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final row = <String, dynamic>{};
|
||||
row[_uploadLocksTable.columnID] = id;
|
||||
row[_uploadLocksTable.columnOwner] = owner;
|
||||
@@ -179,7 +173,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<String> getLockData(String id) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnID} = ?',
|
||||
@@ -196,7 +190,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<bool> isLocked(String id, String owner) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -207,7 +201,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLock(String id, String owner) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -217,7 +211,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -227,7 +221,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseAllLocksAcquiredBefore(int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnTime} < ?',
|
||||
@@ -241,7 +235,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
@@ -268,7 +262,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -291,7 +285,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -349,7 +343,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
final db = await database;
|
||||
|
||||
await db.insert(
|
||||
_streamUploadErrorTable.table,
|
||||
@@ -367,7 +361,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_streamUploadErrorTable.table,
|
||||
{
|
||||
@@ -381,7 +375,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
_streamUploadErrorTable.table,
|
||||
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
|
||||
@@ -390,7 +384,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamUploadError() {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_streamUploadErrorTable.table,
|
||||
columns: [
|
||||
@@ -419,7 +413,7 @@ class UploadLocksDB {
|
||||
String keyNonce, {
|
||||
required int partSize,
|
||||
}) async {
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
final db = await database;
|
||||
final objectKey = urls.objectKey;
|
||||
|
||||
await db.insert(
|
||||
@@ -462,7 +456,7 @@ class UploadLocksDB {
|
||||
int partNumber,
|
||||
String etag,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_partsTable.table,
|
||||
{
|
||||
@@ -479,7 +473,7 @@ class UploadLocksDB {
|
||||
String objectKey,
|
||||
MultipartStatus status,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -493,7 +487,7 @@ class UploadLocksDB {
|
||||
Future<int> deleteMultipartTrack(
|
||||
String localId,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?',
|
||||
@@ -503,7 +497,7 @@ class UploadLocksDB {
|
||||
|
||||
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
|
||||
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
columns: [
|
||||
@@ -525,7 +519,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -546,7 +540,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String queueType, // 'create' or 'recreate'
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
_streamQueueTable.table,
|
||||
{
|
||||
@@ -558,7 +552,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> removeFromStreamQueue(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
@@ -567,7 +561,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamQueue() async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
columns: [
|
||||
@@ -584,7 +578,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<bool> isInStreamQueue(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
|
||||
@@ -1831,7 +1831,7 @@
|
||||
"videosProcessed": "Videos processed",
|
||||
"totalVideos": "Total videos",
|
||||
"skippedVideos": "Skipped videos",
|
||||
"videoStreamingDescription": "Play videos instantly on any device. Enable to process video streams on this device.",
|
||||
"videoStreamingDescription": "Play videos instantly on any device.\nEnable to process video streams on this device.",
|
||||
"videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.",
|
||||
"createStream": "Create stream",
|
||||
"recreateStream": "Recreate stream",
|
||||
@@ -1942,4 +1942,4 @@
|
||||
"almostDone": "Almost done",
|
||||
"processingLocally": "Processing locally",
|
||||
"useMLToFindSimilarImages": "Use ML to find images that look similar to each other."
|
||||
}
|
||||
}
|
||||
53
mobile/apps/photos/lib/models/feed/feed_item.dart
Normal file
53
mobile/apps/photos/lib/models/feed/feed_item.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
enum FeedItemType { memory, photos, video, album }
|
||||
|
||||
class FeedItem {
|
||||
final String id;
|
||||
final FeedItemType type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String userName;
|
||||
final String userAvatarUrl;
|
||||
final bool isFavorite;
|
||||
final List<String> mediaUrls;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const FeedItem({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.userName,
|
||||
required this.userAvatarUrl,
|
||||
required this.isFavorite,
|
||||
required this.mediaUrls,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
FeedItem copyWith({
|
||||
String? id,
|
||||
FeedItemType? type,
|
||||
String? title,
|
||||
String? subtitle,
|
||||
String? userName,
|
||||
String? userAvatarUrl,
|
||||
bool? isFavorite,
|
||||
List<String>? mediaUrls,
|
||||
DateTime? timestamp,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
return FeedItem(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
subtitle: subtitle ?? this.subtitle,
|
||||
userName: userName ?? this.userName,
|
||||
userAvatarUrl: userAvatarUrl ?? this.userAvatarUrl,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
mediaUrls: mediaUrls ?? this.mediaUrls,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
metadata: metadata ?? this.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
mobile/apps/photos/lib/services/feed_service.dart
Normal file
129
mobile/apps/photos/lib/services/feed_service.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
|
||||
class FeedService {
|
||||
static final FeedService _instance = FeedService._internal();
|
||||
static FeedService get instance => _instance;
|
||||
FeedService._internal();
|
||||
|
||||
List<FeedItem> _feedItems = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<FeedItem> get feedItems => List.unmodifiable(_feedItems);
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
void init() {
|
||||
_loadMockData();
|
||||
}
|
||||
|
||||
void _loadMockData() {
|
||||
final now = DateTime.now();
|
||||
debugPrint('FeedService: Loading mock data...');
|
||||
_feedItems = [
|
||||
FeedItem(
|
||||
id: "1",
|
||||
type: FeedItemType.memory,
|
||||
title: "Trip to paris",
|
||||
subtitle: "shared a memory",
|
||||
userName: "Bob",
|
||||
userAvatarUrl: "",
|
||||
isFavorite: false,
|
||||
mediaUrls: [
|
||||
"https://images.unsplash.com/photo-1502602898536-47ad22581b52?w=400&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1527004013197-933c4bb611b3?w=400&h=600&fit=crop",
|
||||
],
|
||||
timestamp: now.subtract(const Duration(hours: 2)),
|
||||
metadata: {"memoryType": "trip"},
|
||||
),
|
||||
FeedItem(
|
||||
id: "2",
|
||||
type: FeedItemType.photos,
|
||||
title: "Maldives",
|
||||
subtitle: "shared 3 photos",
|
||||
userName: "Bob",
|
||||
userAvatarUrl: "",
|
||||
isFavorite: false,
|
||||
mediaUrls: [
|
||||
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop",
|
||||
"https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400&h=300&fit=crop",
|
||||
"https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400&h=300&fit=crop",
|
||||
],
|
||||
timestamp: now.subtract(const Duration(hours: 5)),
|
||||
),
|
||||
FeedItem(
|
||||
id: "3",
|
||||
type: FeedItemType.video,
|
||||
title: "",
|
||||
subtitle: "shared a video",
|
||||
userName: "Bob",
|
||||
userAvatarUrl: "",
|
||||
isFavorite: false,
|
||||
mediaUrls: ["https://images.unsplash.com/photo-1486312338219-ce68e2c6b181?w=400&h=600&fit=crop"],
|
||||
timestamp: now.subtract(const Duration(hours: 8)),
|
||||
metadata: {"videoDuration": 45},
|
||||
),
|
||||
FeedItem(
|
||||
id: "4",
|
||||
type: FeedItemType.album,
|
||||
title: "Pets",
|
||||
subtitle: "shared an Album",
|
||||
userName: "Bob",
|
||||
userAvatarUrl: "",
|
||||
isFavorite: false,
|
||||
mediaUrls: [
|
||||
"https://images.unsplash.com/photo-1415369629372-26f2fe60c467?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1425082661705-1834bfd09dca?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1518717758536-85ae29035b6d?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1511044568932-338cba0ad803?w=400&h=400&fit=crop",
|
||||
],
|
||||
timestamp: now.subtract(const Duration(days: 1)),
|
||||
metadata: {"albumVariant": "pets1"},
|
||||
),
|
||||
FeedItem(
|
||||
id: "5",
|
||||
type: FeedItemType.album,
|
||||
title: "Pets",
|
||||
subtitle: "shared an Album",
|
||||
userName: "Bob",
|
||||
userAvatarUrl: "",
|
||||
isFavorite: false,
|
||||
mediaUrls: [
|
||||
"https://images.unsplash.com/photo-1444212477490-ca407925329e?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1543852786-1cf6624b9987?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1548199973-03cce0bbc87b?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1517423440428-a5a00ad493e8?w=400&h=400&fit=crop",
|
||||
"https://images.unsplash.com/photo-1583337130417-3346a1be7dee?w=400&h=400&fit=crop",
|
||||
],
|
||||
timestamp: now.subtract(const Duration(days: 2)),
|
||||
metadata: {"albumVariant": "pets2"},
|
||||
),
|
||||
];
|
||||
debugPrint('FeedService: Loaded ${_feedItems.length} mock items');
|
||||
}
|
||||
|
||||
Future<void> toggleFavorite(String itemId) async {
|
||||
final itemIndex = _feedItems.indexWhere((item) => item.id == itemId);
|
||||
if (itemIndex != -1) {
|
||||
_feedItems[itemIndex] = _feedItems[itemIndex].copyWith(
|
||||
isFavorite: !_feedItems[itemIndex].isFavorite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshFeed() async {
|
||||
_isLoading = true;
|
||||
|
||||
// Simulate network delay
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// In a real implementation, this would fetch from API
|
||||
_loadMockData();
|
||||
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
List<FeedItem> getFeedItems() {
|
||||
return _feedItems;
|
||||
}
|
||||
}
|
||||
333
mobile/apps/photos/lib/ui/components/feed/album_post.dart
Normal file
333
mobile/apps/photos/lib/ui/components/feed/album_post.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||
|
||||
class AlbumPost extends StatefulWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
|
||||
const AlbumPost({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AlbumPost> createState() => _AlbumPostState();
|
||||
}
|
||||
|
||||
class _AlbumPostState extends State<AlbumPost> {
|
||||
int? selectedSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final variant = widget.item.metadata?["albumVariant"] ?? "pets2";
|
||||
|
||||
return FeedItemCard(
|
||||
item: widget.item,
|
||||
onFavoriteToggle: widget.onFavoriteToggle,
|
||||
child: Container(
|
||||
height: 322,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: variant == "pets1" ? _buildPets1Layout() : _buildPets2Layout(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPets1Layout() {
|
||||
final urls = widget.item.mediaUrls;
|
||||
if (urls.length < 3) return _buildEmptyState();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Back layer
|
||||
Positioned(
|
||||
left: 24,
|
||||
top: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[0],
|
||||
0,
|
||||
width: 270,
|
||||
height: 278,
|
||||
hasOverlay: true,
|
||||
),
|
||||
),
|
||||
// Middle layer
|
||||
Positioned(
|
||||
left: 11,
|
||||
top: 10,
|
||||
child: _buildSelectableImage(
|
||||
urls[1],
|
||||
1,
|
||||
width: 296,
|
||||
height: 306,
|
||||
),
|
||||
),
|
||||
// Front layer with title
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 304,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(urls[2]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => _selectSection(2),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: selectedSection == 2
|
||||
? Border.all(color: Colors.blue, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.center,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPets2Layout() {
|
||||
final urls = widget.item.mediaUrls;
|
||||
if (urls.length < 4) return _buildEmptyState();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Top left
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[0],
|
||||
0,
|
||||
width: 159,
|
||||
height: 161,
|
||||
),
|
||||
),
|
||||
// Bottom left
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 165,
|
||||
child: _buildSelectableImage(
|
||||
urls[1],
|
||||
1,
|
||||
width: 159,
|
||||
height: 157,
|
||||
),
|
||||
),
|
||||
// Top right
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[2],
|
||||
2,
|
||||
width: 156,
|
||||
height: 161,
|
||||
),
|
||||
),
|
||||
// Bottom right with +5 overlay
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 165,
|
||||
child: _buildSelectableImageWithOverlay(
|
||||
urls[3],
|
||||
3,
|
||||
width: 156,
|
||||
height: 157,
|
||||
extraCount: urls.length > 4 ? urls.length - 4 : 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectableImage(
|
||||
String imageUrl,
|
||||
int index, {
|
||||
required double width,
|
||||
required double height,
|
||||
bool hasOverlay = false,
|
||||
}) {
|
||||
final isSelected = selectedSection == index;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _selectSection(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: width,
|
||||
height: height,
|
||||
transform: Matrix4.identity()..scale(isSelected ? 1.05 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (hasOverlay)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.2),
|
||||
Colors.black.withOpacity(0.2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectableImageWithOverlay(
|
||||
String imageUrl,
|
||||
int index, {
|
||||
required double width,
|
||||
required double height,
|
||||
required int extraCount,
|
||||
}) {
|
||||
final isSelected = selectedSection == index;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _selectSection(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: width,
|
||||
height: height,
|
||||
transform: Matrix4.identity()..scale(isSelected ? 1.05 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (extraCount > 0)
|
||||
Positioned(
|
||||
top: 20,
|
||||
right: 20,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+$extraCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.photo_album, color: Colors.grey, size: 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectSection(int index) {
|
||||
setState(() {
|
||||
selectedSection = selectedSection == index ? null : index;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FavoriteButton extends StatelessWidget {
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggle;
|
||||
final double size;
|
||||
|
||||
const FavoriteButton({
|
||||
super.key,
|
||||
required this.isFavorite,
|
||||
required this.onToggle,
|
||||
this.size = 28,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorite ? const Color(0xFF08C225) : const Color(0xFFE0E0E0),
|
||||
size: size * 0.8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
mobile/apps/photos/lib/ui/components/feed/feed_header.dart
Normal file
89
mobile/apps/photos/lib/ui/components/feed/feed_header.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeedHeader extends StatefulWidget {
|
||||
const FeedHeader({super.key});
|
||||
|
||||
@override
|
||||
State<FeedHeader> createState() => _FeedHeaderState();
|
||||
}
|
||||
|
||||
class _FeedHeaderState extends State<FeedHeader> {
|
||||
int notificationCount = 3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Feed',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
notificationCount = 0;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: Colors.black,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
if (notificationCount > 0)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
notificationCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/ui/components/feed/post_header.dart';
|
||||
|
||||
class FeedItemCard extends StatelessWidget {
|
||||
final FeedItem item;
|
||||
final Widget child;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
final double? height;
|
||||
|
||||
const FeedItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.child,
|
||||
required this.onFavoriteToggle,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PostHeader(
|
||||
userName: item.userName,
|
||||
subtitle: item.subtitle,
|
||||
title: item.title,
|
||||
avatarUrl: item.userAvatarUrl,
|
||||
isFavorite: item.isFavorite,
|
||||
onFavoriteToggle: onFavoriteToggle,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
mobile/apps/photos/lib/ui/components/feed/memory_post.dart
Normal file
184
mobile/apps/photos/lib/ui/components/feed/memory_post.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||
|
||||
class MemoryPost extends StatefulWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
|
||||
const MemoryPost({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MemoryPost> createState() => _MemoryPostState();
|
||||
}
|
||||
|
||||
class _MemoryPostState extends State<MemoryPost>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool isImageClicked = false;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.98,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
),);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onImageTapped() {
|
||||
setState(() {
|
||||
isImageClicked = !isImageClicked;
|
||||
});
|
||||
if (isImageClicked) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeedItemCard(
|
||||
item: widget.item,
|
||||
height: 498,
|
||||
onFavoriteToggle: widget.onFavoriteToggle,
|
||||
child: GestureDetector(
|
||||
onTap: _onImageTapped,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 322,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildMemoryLayout(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoryLayout() {
|
||||
final urls = widget.item.mediaUrls;
|
||||
if (urls.isEmpty) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.photo, color: Colors.grey, size: 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Background images in a layered style
|
||||
if (urls.length > 2)
|
||||
Positioned(
|
||||
left: 10,
|
||||
top: 10,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(urls[2]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Main center image
|
||||
if (urls.isNotEmpty)
|
||||
Positioned(
|
||||
left: 50,
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 240,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(urls[0]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.center,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right side image
|
||||
if (urls.length > 1)
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: 10,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(urls[1]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
157
mobile/apps/photos/lib/ui/components/feed/photos_post.dart
Normal file
157
mobile/apps/photos/lib/ui/components/feed/photos_post.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||
|
||||
class PhotosPost extends StatefulWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
|
||||
const PhotosPost({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PhotosPost> createState() => _PhotosPostState();
|
||||
}
|
||||
|
||||
class _PhotosPostState extends State<PhotosPost> {
|
||||
int? selectedImage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeedItemCard(
|
||||
item: widget.item,
|
||||
onFavoriteToggle: widget.onFavoriteToggle,
|
||||
child: Container(
|
||||
height: 322,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildPhotosGrid(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotosGrid() {
|
||||
final urls = widget.item.mediaUrls;
|
||||
if (urls.isEmpty) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.photo, color: Colors.grey, size: 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Top left image
|
||||
if (urls.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[0],
|
||||
0,
|
||||
width: 159,
|
||||
height: 161,
|
||||
),
|
||||
),
|
||||
// Top right image
|
||||
if (urls.length > 1)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[1],
|
||||
1,
|
||||
width: 155,
|
||||
height: 161,
|
||||
),
|
||||
),
|
||||
// Bottom full-width image
|
||||
if (urls.length > 2)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildSelectableImage(
|
||||
urls[2],
|
||||
2,
|
||||
width: double.infinity,
|
||||
height: 157,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectableImage(
|
||||
String imageUrl,
|
||||
int index, {
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
final isSelected = selectedImage == index;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedImage = selectedImage == index ? null : index;
|
||||
});
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: width,
|
||||
height: height,
|
||||
transform: Matrix4.identity()
|
||||
..scale(isSelected ? 1.05 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(color: Colors.blue, width: 2)
|
||||
: null,
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
mobile/apps/photos/lib/ui/components/feed/post_header.dart
Normal file
84
mobile/apps/photos/lib/ui/components/feed/post_header.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/ui/components/feed/favorite_button.dart';
|
||||
import 'package:photos/ui/components/feed/user_avatar.dart';
|
||||
|
||||
class PostHeader extends StatelessWidget {
|
||||
final String userName;
|
||||
final String subtitle;
|
||||
final String title;
|
||||
final String avatarUrl;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
|
||||
const PostHeader({
|
||||
super.key,
|
||||
required this.userName,
|
||||
required this.subtitle,
|
||||
required this.title,
|
||||
required this.avatarUrl,
|
||||
required this.isFavorite,
|
||||
required this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
UserAvatar(
|
||||
avatarUrl: avatarUrl,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
userName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
if (title.isNotEmpty) ...[
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FavoriteButton(
|
||||
isFavorite: isFavorite,
|
||||
onToggle: onFavoriteToggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
mobile/apps/photos/lib/ui/components/feed/user_avatar.dart
Normal file
111
mobile/apps/photos/lib/ui/components/feed/user_avatar.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UserAvatar extends StatelessWidget {
|
||||
final String avatarUrl;
|
||||
final double size;
|
||||
|
||||
const UserAvatar({
|
||||
super.key,
|
||||
required this.avatarUrl,
|
||||
this.size = 48,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF69c3cb),
|
||||
Color(0xFF5fb7bb),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: avatarUrl.isNotEmpty
|
||||
? ClipOval(
|
||||
child: Image.network(
|
||||
avatarUrl,
|
||||
width: size - 4,
|
||||
height: size - 4,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _buildDefaultAvatar(),
|
||||
),
|
||||
)
|
||||
: _buildDefaultAvatar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultAvatar() {
|
||||
return Container(
|
||||
width: size - 4,
|
||||
height: size - 4,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF69c3cb),
|
||||
Color(0xFF5fb7bb),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Blur effects like in React implementation
|
||||
Positioned(
|
||||
left: 7,
|
||||
top: 1,
|
||||
child: Container(
|
||||
width: size * 0.7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6dc8cd).withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(3.5),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3.5),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 7,
|
||||
bottom: 1,
|
||||
child: Container(
|
||||
width: size * 0.7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6dc8cd).withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(3.5),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3.5),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
mobile/apps/photos/lib/ui/components/feed/video_post.dart
Normal file
139
mobile/apps/photos/lib/ui/components/feed/video_post.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/ui/components/feed/feed_item_card.dart';
|
||||
|
||||
class VideoPost extends StatefulWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
|
||||
const VideoPost({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoPost> createState() => _VideoPostState();
|
||||
}
|
||||
|
||||
class _VideoPostState extends State<VideoPost> {
|
||||
bool isPlaying = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeedItemCard(
|
||||
item: widget.item,
|
||||
onFavoriteToggle: widget.onFavoriteToggle,
|
||||
child: Container(
|
||||
height: 322,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildVideoContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoContent() {
|
||||
final urls = widget.item.mediaUrls;
|
||||
if (urls.isEmpty) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.videocam, color: Colors.grey, size: 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
isPlaying = !isPlaying;
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Video thumbnail
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Image.network(
|
||||
urls[0],
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Play overlay
|
||||
if (!isPlaying)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Playing overlay
|
||||
if (isPlaying)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pause,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Playing video...',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
|
||||
),
|
||||
GButton(
|
||||
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
||||
icon: Icons.people_outlined,
|
||||
icon: Icons.rss_feed_outlined,
|
||||
iconColor: enteColorScheme.tabIcon,
|
||||
iconActiveColor: strokeBaseLight,
|
||||
text: '',
|
||||
|
||||
@@ -12,7 +12,6 @@ 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/button_widget.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/menu_item_widget/menu_item_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
@@ -49,7 +48,8 @@ class _VideoStreamingSettingsPageState
|
||||
bottomNavigationBar: !hasEnabled
|
||||
? SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16)
|
||||
.copyWith(bottom: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -75,15 +75,7 @@ class _VideoStreamingSettingsPageState
|
||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||
title: AppLocalizations.of(context).videoStreaming,
|
||||
),
|
||||
actionIcons: [
|
||||
IconButtonWidget(
|
||||
icon: Icons.close_outlined,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
onTap: () {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
},
|
||||
),
|
||||
],
|
||||
actionIcons: const [],
|
||||
isSliver: false,
|
||||
),
|
||||
),
|
||||
@@ -96,17 +88,7 @@ class _VideoStreamingSettingsPageState
|
||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||
title: AppLocalizations.of(context).videoStreaming,
|
||||
),
|
||||
actionIcons: [
|
||||
IconButtonWidget(
|
||||
icon: Icons.close_outlined,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
actionIcons: const [],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
@@ -159,24 +141,27 @@ class _VideoStreamingSettingsPageState
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescription +
|
||||
" ",
|
||||
children: [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).moreDetails,
|
||||
style: TextStyle(
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescription +
|
||||
"\n",
|
||||
children: [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).moreDetails,
|
||||
style: TextStyle(
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = openHelp,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = openHelp,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
style: getEnteTextTheme(context).smallMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
style: getEnteTextTheme(context).smallMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
|
||||
207
mobile/apps/photos/lib/ui/tabs/feed_tab.dart
Normal file
207
mobile/apps/photos/lib/ui/tabs/feed_tab.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_item.dart';
|
||||
import 'package:photos/services/feed_service.dart';
|
||||
import 'package:photos/ui/components/feed/feed_header.dart';
|
||||
|
||||
class FeedTab extends StatefulWidget {
|
||||
const FeedTab({super.key});
|
||||
|
||||
@override
|
||||
State<FeedTab> createState() => _FeedTabState();
|
||||
}
|
||||
|
||||
class _FeedTabState extends State<FeedTab> {
|
||||
final FeedService _feedService = FeedService.instance;
|
||||
List<FeedItem> _feedItems = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_feedService.init();
|
||||
_loadFeedItems();
|
||||
}
|
||||
|
||||
void _loadFeedItems() {
|
||||
setState(() {
|
||||
_feedItems = _feedService.getFeedItems();
|
||||
debugPrint('_loadFeedItems: loaded ${_feedItems.length} items');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
await _feedService.refreshFeed();
|
||||
_loadFeedItems();
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onFavoriteToggle(String itemId) async {
|
||||
await _feedService.toggleFavorite(itemId);
|
||||
_loadFeedItems();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('FeedTab build: _feedItems length = ${_feedItems.length}');
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFAFAFA),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const FeedHeader(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
child: _feedItems.isEmpty
|
||||
? _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
itemCount: _feedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
debugPrint(
|
||||
'Building item $index: ${_feedItems[index].type}',
|
||||
);
|
||||
return _buildFeedItem(_feedItems[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedItem(FeedItem item) {
|
||||
// Simplified version for debugging
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
child: Text(item.userName[0]),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.userName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('${item.subtitle} ${item.title}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
item.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: item.isFavorite ? Colors.red : Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getIconForType(item.type),
|
||||
size: 48,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.type.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForType(FeedItemType type) {
|
||||
switch (type) {
|
||||
case FeedItemType.memory:
|
||||
return Icons.photo_library;
|
||||
case FeedItemType.photos:
|
||||
return Icons.photo;
|
||||
case FeedItemType.video:
|
||||
return Icons.play_circle;
|
||||
case FeedItemType.album:
|
||||
return Icons.photo_album;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.rss_feed,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No posts in your feed yet',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Check back later for updates',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ import 'package:photos/ui/home/start_backup_hook_widget.dart';
|
||||
import 'package:photos/ui/notification/update/change_log_page.dart';
|
||||
import "package:photos/ui/settings/app_update_dialog.dart";
|
||||
import "package:photos/ui/settings_page.dart";
|
||||
import "package:photos/ui/tabs/shared_collections_tab.dart";
|
||||
import "package:photos/ui/tabs/feed_tab.dart";
|
||||
import "package:photos/ui/tabs/user_collections_tab.dart";
|
||||
import "package:photos/ui/viewer/actions/file_viewer.dart";
|
||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||
@@ -87,7 +87,7 @@ class HomeWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeWidgetState extends State<HomeWidget> {
|
||||
static const _sharedCollectionTab = SharedCollectionsTab();
|
||||
static const _feedTab = FeedTab();
|
||||
static const _searchTab = SearchTab();
|
||||
static final _settingsPage = SettingsPage(
|
||||
emailNotifier: UserService.instance.emailValueNotifier,
|
||||
@@ -761,7 +761,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
||||
selectedFiles: _selectedFiles,
|
||||
),
|
||||
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
||||
_sharedCollectionTab,
|
||||
_feedTab,
|
||||
_searchTab,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,395 +1,16 @@
|
||||
import 'dart:async';
|
||||
import "dart:math";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:photos/core/constants.dart";
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/events/tab_changed_event.dart";
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/models/collection/collection_items.dart';
|
||||
import "package:photos/models/search/generic_search_result.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/collections/album/row_item.dart";
|
||||
import "package:photos/ui/collections/collection_list_page.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import "package:photos/ui/components/buttons/icon_button_widget.dart";
|
||||
import 'package:photos/ui/tabs/section_title.dart';
|
||||
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
|
||||
import "package:photos/ui/tabs/shared/empty_state.dart";
|
||||
import "package:photos/ui/tabs/shared/quick_link_album_item.dart";
|
||||
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/standalone/debouncer.dart";
|
||||
|
||||
class SharedCollectionsTab extends StatefulWidget {
|
||||
class SharedCollectionsTab extends StatelessWidget {
|
||||
const SharedCollectionsTab({super.key});
|
||||
|
||||
@override
|
||||
State<SharedCollectionsTab> createState() => _SharedCollectionsTabState();
|
||||
}
|
||||
|
||||
class _SharedCollectionsTabState extends State<SharedCollectionsTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final Logger _logger = Logger("SharedCollectionGallery");
|
||||
late StreamSubscription<LocalPhotosUpdatedEvent> _localFilesSubscription;
|
||||
late StreamSubscription<CollectionUpdatedEvent>
|
||||
_collectionUpdatesSubscription;
|
||||
late StreamSubscription<UserLoggedOutEvent> _loggedOutEvent;
|
||||
final _debouncer = Debouncer(
|
||||
const Duration(seconds: 2),
|
||||
executionInterval: const Duration(seconds: 5),
|
||||
leading: true,
|
||||
);
|
||||
static const heroTagPrefix = "outgoing_collection";
|
||||
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
||||
|
||||
// This can be used to defer loading of widgets in this tab until the tab is
|
||||
// selected for a certain amount of time. This will not turn true until the
|
||||
// user has been in the tab for 500ms. This is to prevent loading widgets when
|
||||
// the user is just switching tabs quickly.
|
||||
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
||||
final _debouncerForDeferringLoad = Debouncer(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
static const maxThumbnailWidth = 224.0;
|
||||
static const crossAxisSpacing = 8.0;
|
||||
static const horizontalPadding = 16.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localFilesSubscription =
|
||||
Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
|
||||
_debouncer.run(() async {
|
||||
if (mounted) {
|
||||
debugPrint("SetState Shared Collections on ${event.reason}");
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
});
|
||||
_collectionUpdatesSubscription =
|
||||
Bus.instance.on<CollectionUpdatedEvent>().listen((event) {
|
||||
_debouncer.run(() async {
|
||||
if (mounted) {
|
||||
debugPrint("SetState Shared Collections on ${event.reason}");
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
});
|
||||
_loggedOutEvent = Bus.instance.on<UserLoggedOutEvent>().listen((event) {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
|
||||
if (event.selectedIndex == 2) {
|
||||
_debouncerForDeferringLoad.run(() async {
|
||||
_logger.info("Loading deferred widgets in shared collections tab");
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = true;
|
||||
await _tabChangeEvent.cancel();
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return FutureBuilder<SharedCollections>(
|
||||
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
if ((snapshot.data?.incoming.length ?? 0) == 0 &&
|
||||
(snapshot.data?.quickLinks.length ?? 0) == 0 &&
|
||||
(snapshot.data?.outgoing.length ?? 0) == 0) {
|
||||
return const Center(child: SharedEmptyStateWidget());
|
||||
}
|
||||
return SafeArea(child: _getSharedCollectionsGallery(snapshot.data!));
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"critical: failed to load share gallery",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return Center(
|
||||
child: Text(AppLocalizations.of(context).somethingWentWrong),
|
||||
);
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getSharedCollectionsGallery(SharedCollections collections) {
|
||||
const maxQuickLinks = 4;
|
||||
final numberOfQuickLinks = collections.quickLinks.length;
|
||||
final double screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final int albumsCountInRow = max(screenWidth ~/ maxThumbnailWidth, 3);
|
||||
final totalHorizontalPadding = (albumsCountInRow - 1) * crossAxisSpacing;
|
||||
final sideOfThumbnail =
|
||||
(screenWidth - totalHorizontalPadding - horizontalPadding) /
|
||||
albumsCountInRow;
|
||||
const quickLinkTitleHeroTag = "quick_link_title";
|
||||
final SectionTitle sharedWithYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedWithYou);
|
||||
final SectionTitle sharedByYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedByYou);
|
||||
final colorTheme = getEnteColorScheme(context);
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 50),
|
||||
child: Column(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SectionOptions(
|
||||
onTap: collections.incoming.isNotEmpty
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.incoming,
|
||||
sectionType: UISectionType.incomingCollections,
|
||||
tag: "incoming",
|
||||
appTitle: sharedWithYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(tag: "incoming", child: sharedWithYou),
|
||||
trailingWidget: collections.incoming.isNotEmpty
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
collections.incoming.isNotEmpty
|
||||
? SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding / 2,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.incoming[index],
|
||||
sideOfThumbnail,
|
||||
tag: "incoming",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.incoming.length,
|
||||
),
|
||||
)
|
||||
: const IncomingAlbumEmptyState(),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SectionOptions(
|
||||
onTap: collections.outgoing.isNotEmpty
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.outgoing,
|
||||
sectionType: UISectionType.outgoingCollections,
|
||||
tag: "outgoing",
|
||||
appTitle: sharedByYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(tag: "outgoing", child: sharedByYou),
|
||||
trailingWidget: collections.outgoing.isNotEmpty
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
collections.outgoing.isNotEmpty
|
||||
? SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.outgoing[index],
|
||||
sideOfThumbnail,
|
||||
tag: "outgoing",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.outgoing.length,
|
||||
),
|
||||
)
|
||||
: const OutgoingAlbumEmptyState(),
|
||||
],
|
||||
),
|
||||
numberOfQuickLinks > 0
|
||||
? Column(
|
||||
children: [
|
||||
SectionOptions(
|
||||
onTap: numberOfQuickLinks > maxQuickLinks
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
AllQuickLinksPage(
|
||||
titleHeroTag: quickLinkTitleHeroTag,
|
||||
quickLinks: collections.quickLinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(
|
||||
tag: quickLinkTitleHeroTag,
|
||||
child: SectionTitle(
|
||||
title: AppLocalizations.of(context).quickLinks,
|
||||
),
|
||||
),
|
||||
trailingWidget: numberOfQuickLinks > maxQuickLinks
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final thumbnail = await CollectionsService
|
||||
.instance
|
||||
.getCover(collections.quickLinks[index]);
|
||||
final page = CollectionPage(
|
||||
CollectionWithThumbnail(
|
||||
collections.quickLinks[index],
|
||||
thumbnail,
|
||||
),
|
||||
tagPrefix: heroTagPrefix,
|
||||
);
|
||||
// ignore: unawaited_futures
|
||||
routeToPage(context, page);
|
||||
},
|
||||
child: QuickLinkAlbumItem(
|
||||
c: collections.quickLinks[index],
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 4);
|
||||
},
|
||||
itemCount: min(numberOfQuickLinks, maxQuickLinks),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(height: 2),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _canLoadDeferredWidgets,
|
||||
builder: (context, value, _) {
|
||||
return value
|
||||
? FutureBuilder(
|
||||
future: SearchService.instance
|
||||
.getAllContactsSearchResults(kSearchSectionLimit),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ContactsSection(
|
||||
snapshot.data as List<GenericSearchResult>,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load contacts section",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const EnteLoadingWidget();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
const CollectPhotosCardWidget(),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
return const Center(
|
||||
child: Text(
|
||||
"Sharing tab is empty",
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
_collectionUpdatesSubscription.cancel();
|
||||
_loggedOutEvent.cancel();
|
||||
_debouncer.cancelDebounceTimer();
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
_tabChangeEvent.cancel();
|
||||
_canLoadDeferredWidgets.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import 'dart:async';
|
||||
import "dart:math";
|
||||
|
||||
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/events/album_sort_order_change_event.dart";
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import "package:photos/events/favorites_service_init_complete_event.dart";
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/events/tab_changed_event.dart";
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/models/collection/collection_items.dart';
|
||||
import "package:photos/models/search/generic_search_result.dart";
|
||||
import "package:photos/models/selected_albums.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/collections/album/row_item.dart";
|
||||
import "package:photos/ui/collections/button/archived_button.dart";
|
||||
import "package:photos/ui/collections/button/hidden_button.dart";
|
||||
import "package:photos/ui/collections/button/trash_button.dart";
|
||||
@@ -25,9 +32,15 @@ import "package:photos/ui/collections/flex_grid_view.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/tabs/section_title.dart";
|
||||
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
|
||||
import "package:photos/ui/tabs/shared/empty_state.dart";
|
||||
import "package:photos/ui/tabs/shared/quick_link_album_item.dart";
|
||||
import "package:photos/ui/viewer/actions/album_selection_overlay_bar.dart";
|
||||
import "package:photos/ui/viewer/actions/delete_empty_albums.dart";
|
||||
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/gallery/empty_state.dart";
|
||||
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/standalone/debouncer.dart";
|
||||
|
||||
@@ -50,6 +63,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
late StreamSubscription<FavoritesServiceInitCompleteEvent>
|
||||
_favoritesServiceInitCompleteEvent;
|
||||
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
|
||||
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
||||
|
||||
String _loadReason = "init";
|
||||
final _scrollController = ScrollController();
|
||||
@@ -59,7 +73,20 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
leading: true,
|
||||
);
|
||||
|
||||
// This can be used to defer loading of widgets in this tab until the tab is
|
||||
// selected for a certain amount of time. This will not turn true until the
|
||||
// user has been in the tab for 500ms. This is to prevent loading widgets when
|
||||
// the user is just switching tabs quickly.
|
||||
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
||||
final _debouncerForDeferringLoad = Debouncer(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
static const int _kOnEnteItemLimitCount = 12;
|
||||
static const heroTagPrefix = "outgoing_collection";
|
||||
static const maxThumbnailWidth = 224.0;
|
||||
static const crossAxisSpacing = 8.0;
|
||||
static const horizontalPadding = 16.0;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -97,6 +124,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_loadReason = event.reason;
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
|
||||
if (event.selectedIndex == 1) {
|
||||
_debouncerForDeferringLoad.run(() async {
|
||||
_logger.info("Loading deferred widgets in collections tab");
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = true;
|
||||
await _tabChangeEvent.cancel();
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,7 +155,23 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
future: CollectionsService.instance.getCollectionForOnEnteSection(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return _getCollectionsGalleryWidget(snapshot.data!);
|
||||
return FutureBuilder<SharedCollections>(
|
||||
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
||||
builder: (context, sharedSnapshot) {
|
||||
if (sharedSnapshot.hasData) {
|
||||
return _getCollectionsGalleryWidget(snapshot.data!, sharedSnapshot.data!);
|
||||
} else if (sharedSnapshot.hasError) {
|
||||
_logger.severe(
|
||||
"Failed to load shared collections",
|
||||
sharedSnapshot.error,
|
||||
sharedSnapshot.stackTrace,
|
||||
);
|
||||
return _getCollectionsGalleryWidget(snapshot.data!, null);
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(snapshot.error.toString());
|
||||
} else {
|
||||
@@ -117,7 +181,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getCollectionsGalleryWidget(List<Collection> collections) {
|
||||
Widget _getCollectionsGalleryWidget(List<Collection> collections, SharedCollections? sharedCollections) {
|
||||
final TextStyle trashAndHiddenTextStyle =
|
||||
Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
color: Theme.of(context)
|
||||
@@ -220,6 +284,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Add shared collections content
|
||||
if (sharedCollections != null) ..._getSharedCollectionsSlivers(sharedCollections),
|
||||
SliverToBoxAdapter(
|
||||
child:
|
||||
SizedBox(height: 64 + MediaQuery.paddingOf(context).bottom),
|
||||
@@ -236,6 +302,279 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getSharedCollectionsSlivers(SharedCollections collections) {
|
||||
const maxQuickLinks = 4;
|
||||
final numberOfQuickLinks = collections.quickLinks.length;
|
||||
final double screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final int albumsCountInRow = max(screenWidth ~/ maxThumbnailWidth, 3);
|
||||
final totalHorizontalPadding = (albumsCountInRow - 1) * crossAxisSpacing;
|
||||
final sideOfThumbnail =
|
||||
(screenWidth - totalHorizontalPadding - horizontalPadding) /
|
||||
albumsCountInRow;
|
||||
const quickLinkTitleHeroTag = "quick_link_title";
|
||||
final SectionTitle sharedWithYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedWithYou);
|
||||
final SectionTitle sharedByYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedByYou);
|
||||
final colorTheme = getEnteColorScheme(context);
|
||||
|
||||
List<Widget> slivers = [];
|
||||
|
||||
// Add divider before shared content
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Divider(
|
||||
color: getEnteColorScheme(context).strokeFaint,
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 12)));
|
||||
|
||||
// Shared with you section
|
||||
if (collections.incoming.isNotEmpty ||
|
||||
((collections.incoming.length ?? 0) == 0 &&
|
||||
(collections.quickLinks.length ?? 0) == 0 &&
|
||||
(collections.outgoing.length ?? 0) == 0)) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: SectionOptions(
|
||||
onTap: collections.incoming.isNotEmpty
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.incoming,
|
||||
sectionType: UISectionType.incomingCollections,
|
||||
tag: "incoming",
|
||||
appTitle: sharedWithYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(tag: "incoming", child: sharedWithYou),
|
||||
trailingWidget: collections.incoming.isNotEmpty
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: collections.incoming.isNotEmpty
|
||||
? SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding / 2,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.incoming[index],
|
||||
sideOfThumbnail,
|
||||
tag: "incoming",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.incoming.length,
|
||||
),
|
||||
)
|
||||
: const IncomingAlbumEmptyState(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Shared by you section
|
||||
if (collections.outgoing.isNotEmpty ||
|
||||
((collections.incoming.length ?? 0) == 0 &&
|
||||
(collections.quickLinks.length ?? 0) == 0 &&
|
||||
(collections.outgoing.length ?? 0) == 0)) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: SectionOptions(
|
||||
onTap: collections.outgoing.isNotEmpty
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.outgoing,
|
||||
sectionType: UISectionType.outgoingCollections,
|
||||
tag: "outgoing",
|
||||
appTitle: sharedByYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(tag: "outgoing", child: sharedByYou),
|
||||
trailingWidget: collections.outgoing.isNotEmpty
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: collections.outgoing.isNotEmpty
|
||||
? SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.outgoing[index],
|
||||
sideOfThumbnail,
|
||||
tag: "outgoing",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.outgoing.length,
|
||||
),
|
||||
)
|
||||
: const OutgoingAlbumEmptyState(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Quick links section
|
||||
if (numberOfQuickLinks > 0) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: SectionOptions(
|
||||
onTap: numberOfQuickLinks > maxQuickLinks
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
AllQuickLinksPage(
|
||||
titleHeroTag: quickLinkTitleHeroTag,
|
||||
quickLinks: collections.quickLinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(
|
||||
tag: quickLinkTitleHeroTag,
|
||||
child: SectionTitle(
|
||||
title: AppLocalizations.of(context).quickLinks,
|
||||
),
|
||||
),
|
||||
trailingWidget: numberOfQuickLinks > maxQuickLinks
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final thumbnail = await CollectionsService
|
||||
.instance
|
||||
.getCover(collections.quickLinks[index]);
|
||||
final page = CollectionPage(
|
||||
CollectionWithThumbnail(
|
||||
collections.quickLinks[index],
|
||||
thumbnail,
|
||||
),
|
||||
tagPrefix: heroTagPrefix,
|
||||
);
|
||||
// ignore: unawaited_futures
|
||||
routeToPage(context, page);
|
||||
},
|
||||
child: QuickLinkAlbumItem(
|
||||
c: collections.quickLinks[index],
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 4);
|
||||
},
|
||||
itemCount: min(numberOfQuickLinks, maxQuickLinks),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 2)));
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _canLoadDeferredWidgets,
|
||||
builder: (context, value, _) {
|
||||
return value
|
||||
? FutureBuilder(
|
||||
future: SearchService.instance
|
||||
.getAllContactsSearchResults(kSearchSectionLimit),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ContactsSection(
|
||||
snapshot.data as List<GenericSearchResult>,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load contacts section",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const EnteLoadingWidget();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
slivers.add(const SliverToBoxAdapter(child: CollectPhotosCardWidget()));
|
||||
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 32)));
|
||||
|
||||
return slivers;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
@@ -244,7 +583,10 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_favoritesServiceInitCompleteEvent.cancel();
|
||||
_scrollController.dispose();
|
||||
_debouncer.cancelDebounceTimer();
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
_albumSortOrderChangeEvent.cancel();
|
||||
_tabChangeEvent.cancel();
|
||||
_canLoadDeferredWidgets.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import "package:permission_handler/permission_handler.dart";
|
||||
import "package:photos/db/upload_locks_db.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/main.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/utils/file_uploader.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
import "package:workmanager/workmanager.dart" as workmanager;
|
||||
@@ -81,7 +80,7 @@ class BgTaskUtils {
|
||||
try {
|
||||
await workmanager.Workmanager().initialize(
|
||||
callbackDispatcher,
|
||||
isInDebugMode: Platform.isIOS && flagService.internalUser,
|
||||
isInDebugMode: false,
|
||||
);
|
||||
await workmanager.Workmanager().registerPeriodicTask(
|
||||
backgroundTaskIdentifier,
|
||||
|
||||
Reference in New Issue
Block a user