diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..c2b0b83 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..27cf69a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +# Pull Request + +## Description + + +## Related Issues + + +## Type of Change +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +## How Has This Been Tested? + +- [ ] Unit tests +- [ ] Integration tests +- [ ] Manual testing + +## Checklist: +- [ ] My code follows the project's coding style +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..c65a0c2 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,57 @@ +name: Android CI + +on: + push: + branches: [ main, develop, production ] + pull_request: + branches: [ main, develop, production ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Create dummy google-services.json + run: | + echo '{ + "project_info": { + "project_number": "123456789", + "project_id": "dummy-project", + "storage_bucket": "dummy-project.appspot.com" + }, + "client": [{ + "client_info": { + "mobilesdk_app_id": "1:123456789:android:abcdef123456", + "android_client_info": { + "package_name": "com.pineapple.capture" + } + }, + "api_key": [{ + "current_key": "dummy_api_key" + }], + "oauth_client": [], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }] + }' > app/google-services.json + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build --stacktrace + + - name: Run Tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec8a5ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +*.iml +.gradle +.vscode +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Firebase configuration file +google-services.json +app/google-services.json + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +.gradle/**/* + +# IDE files +.vscode/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ + +# Gradle cache files +.gradle/ +build/ +.idea/ +local.properties + +# Android Studio generated files +*.iml +.idea/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Android Profiling +*.hprof diff --git a/README.md b/README.md index 4401384..ffb8c8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,32 @@ # Capture - Android App -A Java-based Android application. +A Java-based Android application. Capture is a real-time social diary that lets you stay truly connected with your closest friends. Share spontaneous photos and thoughts directly to your friends’ home screens – as it happens. No filters, no algorithms, just raw, real-life moments. + +Whether it’s a quick smile or a funny thought – Capture makes every little moment meaningful. It’s not about likes or followers. It’s about living and sharing life together, in real time. + +## Project overview + +### Features + +- Take, post real-time pictures +- Like, share, comments on posts +- Lightweight, fast, and privacy-focused + +### Team Members + +- Phuong Khanh Pham +- Natthanicha Vongjarit +- Debojyoti Mishra +- Bui Tien Quoc + +### Tech Stack + +- **Language**: Java +- **Platform**: Android SDK (API level 26+) +- **IDE**: Android Studio +- **Version Control**: Git + GitHub +- **CI/CD**: GitHub Actions ## Branching Strategy @@ -76,7 +101,125 @@ When the develop branch is stable and ready for release: ## Development Setup -[Add your development setup instructions here] +### Requirements + +- Android Studio Iguana (2023.2.1) or newer +- JDK 17 or higher +- Android SDK with minimum API level 26 (Android 8.0 Oreo) +- Git + +### Getting Started + +1. Clone the repository: + ```bash + git clone https://github.com/arcreane/android-project-pineapple.git + cd android-project-pineapple + ``` + +2. Set up the git hooks: + ```bash + ./scripts/setup-hooks.sh + ``` + +3. Open the project in Android Studio: + - Launch Android Studio + - Select "Open an existing project" + - Navigate to the cloned repository and click "Open" + +4. Sync the project with Gradle files: + - Android Studio should automatically sync + - If not, select "File > Sync Project with Gradle Files" + +5. Run the app: + - Connect an Android device or use an emulator + - Click the "Run" button (green triangle) in Android Studio + +### Troubleshooting + +- If you encounter Gradle sync issues, ensure you have the correct JDK version +- For build errors, check the "Build" tab in Android Studio for specific error messages +- If GitHub Actions CI builds fail but local builds succeed, check Java version compatibility + +## Contributing + +### First-Time Setup + +1. Fork the repository on GitHub: + - Visit the repository page on GitHub + - Click the "Fork" button in the top-right corner + - Select your account as the destination + +2. Clone your forked repository: + ```bash + git clone https://github.com/YOUR_USERNAME/android-project-pineapple.git + cd android-project-pineapple + ``` + +3. Add the original repository as upstream: + ```bash + git remote add upstream https://github.com/arcreane/android-project-pineapple.git + ``` + +### Making Contributions + +1. Sync your local develop branch: + ```bash + git checkout develop + git pull upstream develop + git push origin develop + ``` + +2. Create a new feature branch: + ```bash + git checkout -b feature/your-feature-name develop + ``` + Branch naming convention: + - `feature/*` for new features + - `bugfix/*` for bug fixes + - `docs/*` for documentation changes + - `hotfix/*` for urgent production fixes + +3. Make your changes: + - Write clear, concise commit messages following our convention: + ```bash + git commit -m "type(scope): description" + ``` + - Types: feat, fix, docs, style, refactor, test, chore + - Example: `git commit -m "feat(login): add biometric authentication"` + +4. Push your changes: + ```bash + git push -u origin feature/your-feature-name + ``` + +5. Create a Pull Request: + - Go to the original repository on GitHub + - Click "Pull Requests" > "New Pull Request" + - Select `develop` as the base branch + - Write a clear PR description explaining your changes + - Link any related issues + +6. Address review feedback: + - Make requested changes in your feature branch + - Commit and push updates + - The PR will update automatically + +7. After PR is merged: + ```bash + git checkout develop + git pull upstream develop + git push origin develop + git branch -d feature/your-feature-name + ``` + +### Best Practices + +- Keep PRs focused and reasonably sized +- Update your feature branch regularly with develop +- Write meaningful commit messages +- Add tests for new features +- Update documentation as needed +- Ensure CI checks pass before requesting review ## UML Diagram ![UML Diagram](UML.svg) \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore index 42afabf..056f29c 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,3 @@ -/build \ No newline at end of file +/build +.gradle/file-system.probe +.DS_Store \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index b04e2fe..7026fa2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,14 @@ plugins { id 'com.android.application' + id 'com.google.gms.google-services' +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } } android { @@ -14,6 +23,11 @@ android { versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // Read Cloudinary credentials from local.properties + buildConfigField "String", "CLOUDINARY_CLOUD_NAME", "\"${localProperties.getProperty('CLOUDINARY_CLOUD_NAME', '')}\"" + buildConfigField "String", "CLOUDINARY_API_KEY", "\"${localProperties.getProperty('CLOUDINARY_API_KEY', '')}\"" + buildConfigField "String", "CLOUDINARY_API_SECRET", "\"${localProperties.getProperty('CLOUDINARY_API_SECRET', '')}\"" } buildTypes { @@ -27,6 +41,7 @@ android { targetCompatibility JavaVersion.VERSION_17 } buildFeatures { + buildConfig true viewBinding true } } @@ -36,6 +51,27 @@ dependencies { implementation 'androidx.core:core:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01' +// implementation platform('com.google.firebase:firebase-bom:32.7.4') +// implementation 'com.google.firebase:firebase-common' + implementation("com.google.firebase:firebase-firestore:25.0.0") + implementation 'com.google.guava:guava:32.1.2-android' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime:2.7.0' + implementation 'androidx.navigation:navigation-fragment:2.7.7' + implementation 'androidx.navigation:navigation-ui:2.7.7' + implementation "androidx.camera:camera-core:1.3.0" + implementation "androidx.camera:camera-camera2:1.3.0" + implementation "androidx.camera:camera-lifecycle:1.3.0" + implementation "androidx.camera:camera-view:1.3.0" + implementation 'de.hdodenhof:circleimageview:3.1.0' + implementation libs.firebase.auth + implementation libs.firebase.storage +// implementation libs.firebase.inappmessaging + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' + implementation("com.cloudinary:cloudinary-android:3.0.2") testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/git_test.txt b/app/git_test.txt new file mode 100644 index 0000000..865c2d0 --- /dev/null +++ b/app/git_test.txt @@ -0,0 +1 @@ +test ev \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 091386d..e48c0b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,27 +2,83 @@ + + + + + + + + + + + + + + + + + + android:theme="@style/Theme.Capture.NoActionBar"> - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..ece3315 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/pineapple/capture/MainActivity.java b/app/src/main/java/com/pineapple/capture/MainActivity.java index b2225ed..3dbf4dd 100644 --- a/app/src/main/java/com/pineapple/capture/MainActivity.java +++ b/app/src/main/java/com/pineapple/capture/MainActivity.java @@ -1,11 +1,27 @@ package com.pineapple.capture; +import android.content.Intent; import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.ViewCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.firebase.auth.FirebaseAuth; +import com.pineapple.capture.activities.LoginActivity; import com.pineapple.capture.databinding.ActivityMainBinding; +import com.pineapple.capture.fragment.CameraFragment; +import com.pineapple.capture.fragment.HomeFragment; +import com.pineapple.capture.fragment.ProfileFragment; public class MainActivity extends AppCompatActivity { private ActivityMainBinding binding; + private FirebaseAuth mAuth; @Override protected void onCreate(Bundle savedInstanceState) { @@ -14,6 +30,90 @@ protected void onCreate(Bundle savedInstanceState) { binding = ActivityMainBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); - binding.textViewGreeting.setText("Hello Android!"); + MenuItem cameraItem = binding.bottomNavigation.getMenu().findItem(R.id.navigation_camera); + cameraItem.setIconTintList(null); + + mAuth = FirebaseAuth.getInstance(); + + /* Set up toolbar + Toolbar toolbar = binding.toolbar; + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayShowTitleEnabled(false); + } + */ + + // Remove bottom navigation padding + ViewCompat.setOnApplyWindowInsetsListener(binding.bottomNavigation, (v, insets) -> { + v.setPadding(0, 0, 0, 0); + return insets; + }); + + // Set up bottom navigation + binding.bottomNavigation.setOnItemSelectedListener(item -> { + if (item.getItemId() == R.id.navigation_home) { + loadFragment(new HomeFragment()); + return true; + } else if (item.getItemId() == R.id.navigation_camera) { + loadFragment(new CameraFragment()); + return true; + } else if (item.getItemId() == R.id.navigation_profile) { + loadFragment(new ProfileFragment()); + return true; + } + return false; + }); + + // Start with home fragment if this is a fresh start + if (savedInstanceState == null) { + loadFragment(new HomeFragment()); + binding.bottomNavigation.setSelectedItemId(R.id.navigation_home); + } + } + + // Helper method to load fragments + private void loadFragment(Fragment fragment) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit(); + } + + @Override + protected void onResume() { + super.onResume(); + + // Always refresh the HomeFragment when returning to the app + if (binding.bottomNavigation.getSelectedItemId() == R.id.navigation_home) { + HomeFragment homeFragment = (HomeFragment) getSupportFragmentManager() + .findFragmentById(R.id.fragment_container); + + if (homeFragment != null) { + homeFragment.loadFeedPosts(); + } + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.action_logout) { + logout(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void logout() { + mAuth.signOut(); + Intent intent = new Intent(this, LoginActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); } } \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/activities/InterestsActivity.java b/app/src/main/java/com/pineapple/capture/activities/InterestsActivity.java new file mode 100644 index 0000000..c997cb2 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/activities/InterestsActivity.java @@ -0,0 +1,374 @@ +package com.pineapple.capture.activities; + +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.google.firebase.firestore.FirebaseFirestore; +import com.pineapple.capture.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class InterestsActivity extends AppCompatActivity { + + private FirebaseFirestore db; + private FirebaseUser currentUser; + private TextView selectedCountText; + private Button saveButton; + private List selectedInterests = new ArrayList<>(); + private final int MAX_SELECTIONS = 6; + + // interest categories and their options + private final Map> interestCategories = new HashMap>() {{ + put("Creativity", Arrays.asList("Art", "Dancing", "Make-up", "Video", "Cosplay", "Design", "Photography", "Crafts", "Fashion", "Singing")); + put("Sports", Arrays.asList("Badminton", "Bouldering", "Crew", "Baseball", "Bowling", "Cricket", "Basketball", "Boxing", "Cycling")); + put("Pets", Arrays.asList("Amphibians", "Cats", "Horses", "Arthropods", "Dogs", "Rabbits", "Birds", "Fish", "Reptiles", "Turtles")); + }}; + + // Map to store interest colors + private Map interestColors = new HashMap<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_interests); + + db = FirebaseFirestore.getInstance(); + currentUser = FirebaseAuth.getInstance().getCurrentUser(); + + selectedCountText = findViewById(R.id.interests_count); + saveButton = findViewById(R.id.save_button); + ImageButton backButton = findViewById(R.id.back_button); + Button clearAllButton = findViewById(R.id.clear_all_button); + + for (List interestList : interestCategories.values()) { + for (String interest : interestList) { + interestColors.put(interest, getInterestCategoryColor(interest)); + } + } + + setupInterestCategories(); + updateSelectedCount(); + + backButton.setOnClickListener(v -> finish()); + saveButton.setOnClickListener(v -> saveInterests()); + + findViewById(R.id.help_button).setOnClickListener(v -> { + showHelpDialog(); + }); + + clearAllButton.setOnClickListener(v -> { + if (!selectedInterests.isEmpty()) { + new AlertDialog.Builder(this) + .setTitle("Clear All Interests") + .setMessage("Are you sure you want to remove all selected interests?") + .setPositiveButton("Clear All", (dialog, which) -> { + clearAllInterests(); + }) + .setNegativeButton("Cancel", null) + .show(); + } else { + Toast.makeText(this, "No interests selected", Toast.LENGTH_SHORT).show(); + } + }); + + loadExistingInterests(); + } + + private void setupInterestCategories() { + LinearLayout interestsContainer = findViewById(R.id.interests_container); + + for (Map.Entry> category : interestCategories.entrySet()) { + TextView categoryTitle = new TextView(this); + categoryTitle.setText(category.getKey()); + categoryTitle.setTextSize(24); + categoryTitle.setTextColor(getResources().getColor(R.color.white)); + categoryTitle.setPadding(0, 40, 0, 20); + interestsContainer.addView(categoryTitle); + + ChipGroup chipGroup = new ChipGroup(this); + chipGroup.setChipSpacingHorizontal(16); + chipGroup.setChipSpacingVertical(16); + + for (String interest : category.getValue()) { + Chip chip = new Chip(this); + chip.setText(interest); + chip.setCheckable(true); + chip.setClickable(true); + + chip.setChipBackgroundColorResource(R.color.background_dark); + chip.setTextColor(getResources().getColor(R.color.white)); + chip.setChipStrokeWidth(1); + chip.setChipStrokeColorResource(R.color.white); + + setChipIcon(chip, interest); + + chip.setTag(interestColors.get(interest)); + + chip.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + if (selectedInterests.size() >= MAX_SELECTIONS) { + chip.setChecked(false); + Toast.makeText(InterestsActivity.this, + "You can select up to " + MAX_SELECTIONS + " interests", + Toast.LENGTH_SHORT).show(); + } else { + selectedInterests.add(interest); + chip.setChipBackgroundColorResource(interestColors.get(interest)); + chip.setTextColor(getResources().getColor(R.color.black)); + chip.setElevation(2f); + chip.setChipCornerRadius(16f); + } + } else { + selectedInterests.remove(interest); + chip.setChipBackgroundColorResource(R.color.background_dark); + chip.setTextColor(getResources().getColor(R.color.white)); + chip.setElevation(0f); + chip.setChipCornerRadius(8f); + } + updateSelectedCount(); + }); + + chipGroup.addView(chip); + } + + interestsContainer.addView(chipGroup); + } + + Toast.makeText(this, "Tip: Tap an interest to select/deselect it", Toast.LENGTH_LONG).show(); + } + + private void setChipIcon(Chip chip, String interest) { + // Set appropriate icon for each interest + // This would ideally use a mapping or switch statement to set the correct drawable + // For simplicity, using placeholder approach + int iconResId = getInterestIconResource(interest); + if (iconResId != 0) { + chip.setChipIconResource(iconResId); + chip.setChipIconVisible(true); + } + } + + private int getInterestIconResource(String interest) { + String lowerInterest = interest.toLowerCase(); + + if (lowerInterest.equals("art")) return R.drawable.ic_art; + if (lowerInterest.equals("dancing")) return R.drawable.ic_dancing; + if (lowerInterest.equals("photography")) return R.drawable.ic_photography; + if (lowerInterest.equals("singing")) return R.drawable.ic_music; + + if (Arrays.asList("make-up", "design", "crafts", "fashion", "video", "cosplay").contains(lowerInterest)) { + return R.drawable.ic_creativity; + } + + if (Arrays.asList("badminton", "bouldering", "crew", "baseball", "bowling", "cricket", "basketball", "boxing", "cycling").contains(lowerInterest)) { + return R.drawable.ic_sports; + } + + if (Arrays.asList("amphibians", "cats", "horses", "arthropods", "dogs", "rabbits", "birds", "fish", "reptiles", "turtles").contains(lowerInterest)) { + return R.drawable.ic_pets; + } + + return R.drawable.ic_favorite; // Default icon + } + + private void updateSelectedCount() { + int count = selectedInterests.size(); + selectedCountText.setText(String.format("%d/%d selected", count, MAX_SELECTIONS)); + + Button clearAllButton = findViewById(R.id.clear_all_button); + clearAllButton.setVisibility(count > 0 ? View.VISIBLE : View.GONE); + + if (count > 0) { + clearAllButton.setBackgroundResource(R.drawable.rounded_button_secondary); + } + + saveButton.setEnabled(count > 0); + } + + private void loadExistingInterests() { + if (currentUser != null) { + db.collection("users").document(currentUser.getUid()) + .get() + .addOnSuccessListener(documentSnapshot -> { + if (documentSnapshot.exists() && documentSnapshot.contains("interests")) { + List userInterests = (List) documentSnapshot.get("interests"); + if (userInterests != null && !userInterests.isEmpty()) { + selectedInterests = new ArrayList<>(userInterests); + updateChipSelections(); + updateSelectedCount(); // update the Clear All button visibility + } + } + }) + .addOnFailureListener(e -> + Toast.makeText(this, "Failed to load interests: " + e.getMessage(), + Toast.LENGTH_SHORT).show()); + } + } + + private void updateChipSelections() { + for (String category : interestCategories.keySet()) { + ChipGroup chipGroup = findChipGroupForCategory(category); + if (chipGroup != null) { + for (int i = 0; i < chipGroup.getChildCount(); i++) { + View child = chipGroup.getChildAt(i); + if (child instanceof Chip) { + Chip chip = (Chip) child; + String interestName = chip.getText().toString(); + boolean isSelected = selectedInterests.contains(interestName); + chip.setChecked(isSelected); + + if (isSelected) { + chip.setChipBackgroundColorResource(interestColors.get(interestName)); + chip.setTextColor(getResources().getColor(R.color.black)); + chip.setElevation(2f); + chip.setChipCornerRadius(16f); + } else { + chip.setChipBackgroundColorResource(R.color.background_dark); + chip.setTextColor(getResources().getColor(R.color.white)); + chip.setElevation(0f); + chip.setChipCornerRadius(8f); + } + } + } + } + } + } + + private ChipGroup findChipGroupForCategory(String category) { + LinearLayout container = findViewById(R.id.interests_container); + boolean foundCategory = false; + + for (int i = 0; i < container.getChildCount(); i++) { + View child = container.getChildAt(i); + + if (child instanceof TextView && ((TextView) child).getText().equals(category)) { + foundCategory = true; + } else if (foundCategory && child instanceof ChipGroup) { + return (ChipGroup) child; + } + } + + return null; + } + + private void saveInterests() { + if (currentUser != null) { + db.collection("users").document(currentUser.getUid()) + .update("interests", selectedInterests) + .addOnSuccessListener(aVoid -> { + Toast.makeText(InterestsActivity.this, + "Interests saved successfully", + Toast.LENGTH_SHORT).show(); + + setResult(RESULT_OK); + finish(); + }) + .addOnFailureListener(e -> + Toast.makeText(InterestsActivity.this, + "Failed to save interests: " + e.getMessage(), + Toast.LENGTH_SHORT).show()); + } + } + + /** + * Assigns consistent colors to interests based on their category + */ + private int getInterestCategoryColor(String interest) { + String lowerInterest = interest.toLowerCase(); + + // Creativity (orange/yellow) + if (Arrays.asList("art", "design", "photography", "crafts", "fashion", "singing", "dancing", "video", "cosplay", "make-up").contains(lowerInterest)) { + if (lowerInterest.equals("art")) return R.color.pastel_orange; + if (lowerInterest.equals("dancing")) return R.color.pastel_yellow; + if (lowerInterest.equals("photography")) return R.color.pastel_orange; + if (lowerInterest.equals("singing")) return R.color.pastel_yellow; + return R.color.pastel_orange; + } + + // Sports (blue/green) + if (Arrays.asList("badminton", "bouldering", "crew", "baseball", "bowling", "cricket", "basketball", "boxing", "cycling").contains(lowerInterest)) { + if (lowerInterest.equals("basketball")) return R.color.pastel_blue; + if (lowerInterest.equals("cycling")) return R.color.pastel_green; + if (lowerInterest.equals("baseball")) return R.color.pastel_blue; + return R.color.pastel_green; + } + + // Pets (purple/pink) + if (Arrays.asList("amphibians", "cats", "horses", "arthropods", "dogs", "rabbits", "birds", "fish", "reptiles", "turtles").contains(lowerInterest)) { + if (lowerInterest.equals("cats")) return R.color.pastel_purple; + if (lowerInterest.equals("dogs")) return R.color.pastel_pink; + if (lowerInterest.equals("birds")) return R.color.pastel_purple; + return R.color.pastel_pink; + } + + // Default colors based on first letter for any other interest + char firstChar = lowerInterest.charAt(0); + switch (firstChar % 8) { + case 0: return R.color.pastel_blue; + case 1: return R.color.pastel_green; + case 2: return R.color.pastel_purple; + case 3: return R.color.pastel_pink; + case 4: return R.color.pastel_orange; + case 5: return R.color.pastel_yellow; + case 6: return R.color.pastel_teal; + case 7: return R.color.pastel_cyan; + default: return R.color.pastel_blue; + } + } + + private void showHelpDialog() { + new AlertDialog.Builder(this) + .setTitle("How to Use") + .setMessage("• Tap an interest to select it\n• Tap again to remove it\n• Use 'Clear All' to remove all selections\n• You can select up to " + MAX_SELECTIONS + " interests\n• Selected interests will appear on your profile") + .setPositiveButton("Got it", null) + .show(); + } + + /** + * Clears all selected interests + */ + private void clearAllInterests() { + selectedInterests.clear(); + clearAllChips(); + updateSelectedCount(); + Toast.makeText(this, "All interests cleared", Toast.LENGTH_SHORT).show(); + } + + /** + * Resets all chips to unselected state + */ + private void clearAllChips() { + for (String category : interestCategories.keySet()) { + ChipGroup chipGroup = findChipGroupForCategory(category); + if (chipGroup != null) { + for (int i = 0; i < chipGroup.getChildCount(); i++) { + View child = chipGroup.getChildAt(i); + if (child instanceof Chip) { + Chip chip = (Chip) child; + chip.setChecked(false); + chip.setChipBackgroundColorResource(R.color.background_dark); + chip.setTextColor(getResources().getColor(R.color.white)); + chip.setElevation(0f); + chip.setChipCornerRadius(8f); + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/activities/LoginActivity.java b/app/src/main/java/com/pineapple/capture/activities/LoginActivity.java new file mode 100644 index 0000000..eb6764e --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/activities/LoginActivity.java @@ -0,0 +1,113 @@ +package com.pineapple.capture.activities; + +import android.content.Intent; //changing screen +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import com.pineapple.capture.MainActivity; +import com.pineapple.capture.R; +import com.pineapple.capture.auth.AuthManager; +import com.pineapple.capture.databinding.ActivityLoginBinding; +import com.pineapple.capture.utils.NetworkUtils; + +public class LoginActivity extends AppCompatActivity { + private ActivityLoginBinding binding; + private AuthManager authManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(R.style.Theme_Capture_NoActionBar); + super.onCreate(savedInstanceState); + binding = ActivityLoginBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + authManager = AuthManager.getInstance(); + + if (authManager.getCurrentUser() != null) { + startMainActivity(); + finish(); + return; + } + + setupClickListeners(); + } + + private void setupClickListeners() { + binding.loginButton.setOnClickListener(v -> handleLogin()); + binding.signupButton.setOnClickListener(v -> startSignupActivity()); + binding.resetPasswordLink.setOnClickListener(v -> handleResetPassword()); + } + + private void handleLogin() { + if (!NetworkUtils.isConnected(LoginActivity.this)) { + Toast.makeText(this, "No internet connection", Toast.LENGTH_SHORT).show(); + return; + } + + String email = binding.emailEditText.getText().toString().trim(); + String password = binding.passwordEditText.getText().toString().trim(); + + if (email.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "Please fill in all fields", Toast.LENGTH_SHORT).show(); + return; + } + + showLoading(true); + authManager.login(email, password) + .addOnCompleteListener(task -> { + showLoading(false); + if (task.isSuccessful()) { + startMainActivity(); + finish(); + } else { + String error = task.getException() != null ? + task.getException().getMessage() : + "Authentication failed"; + Toast.makeText(LoginActivity.this, error, Toast.LENGTH_LONG).show(); + } + }); + } + + private void startSignupActivity() { + Intent intent = new Intent(this, SignupActivity.class); + startActivity(intent); + } + + private void handleResetPassword() { + String email = binding.emailEditText.getText().toString().trim(); + if (email.isEmpty()) { + Toast.makeText(this, "Please enter your email", Toast.LENGTH_SHORT).show(); + return; + } + + showLoading(true); + authManager.sendPasswordResetEmail(email) + .addOnCompleteListener(task -> { + showLoading(false); + if (task.isSuccessful()) { + Toast.makeText(this, "Password reset email sent", Toast.LENGTH_SHORT).show(); + } else { + String error = task.getException() != null ? + task.getException().getMessage() : + "Failed to send reset password email"; + Toast.makeText(LoginActivity.this, error, Toast.LENGTH_LONG).show(); + + } + }); + } + + private void startMainActivity() { + Intent intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } + + private void showLoading(boolean show) { + binding.progressBar.setVisibility(show ? View.VISIBLE : View.GONE); + binding.loginButton.setEnabled(!show); + binding.signupButton.setEnabled(!show); + binding.emailEditText.setEnabled(!show); + binding.passwordEditText.setEnabled(!show); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/activities/SignupActivity.java b/app/src/main/java/com/pineapple/capture/activities/SignupActivity.java new file mode 100644 index 0000000..8b10ebc --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/activities/SignupActivity.java @@ -0,0 +1,104 @@ +package com.pineapple.capture.activities; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import com.pineapple.capture.MainActivity; +import com.pineapple.capture.auth.AuthManager; +import com.pineapple.capture.databinding.ActivitySignupBinding; +import com.pineapple.capture.utils.NetworkUtils; + +public class SignupActivity extends AppCompatActivity { + + private ActivitySignupBinding binding; + private AuthManager authManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivitySignupBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + authManager = AuthManager.getInstance(); + + setupClickListeners(); + } + + private void setupClickListeners() { + binding.signupButton.setOnClickListener(v -> handleSignup()); + binding.loginButton.setOnClickListener(v -> finish()); + } + + private void handleSignup() { + if (!NetworkUtils.isConnected(this)) { + Toast.makeText(this, "No internet connection", Toast.LENGTH_SHORT).show(); + return; + } + + String displayName = binding.displayNameEditText.getText().toString().trim(); + String username = binding.usernameEditText.getText().toString().trim(); + String email = binding.emailEditText.getText().toString().trim(); + String password = binding.passwordEditText.getText().toString().trim(); + + if (displayName.isEmpty() || username.isEmpty() || email.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "Please fill in all fields", Toast.LENGTH_SHORT).show(); + return; + } + + // Username validation + if (username.length() < 1 || username.length() > 30) { + Toast.makeText(this, "Username must be 1-30 characters", Toast.LENGTH_SHORT).show(); + return; + } + if (username.contains(" ")) { + Toast.makeText(this, "Username cannot contain spaces", Toast.LENGTH_SHORT).show(); + return; + } + if (!username.matches("^[a-zA-Z0-9._]+$")) { + Toast.makeText(this, "Username can only contain letters, numbers, periods, and underscores", Toast.LENGTH_SHORT).show(); + return; + } + if (username.startsWith(".") || username.endsWith(".")) { + Toast.makeText(this, "Username cannot start or end with a period", Toast.LENGTH_SHORT).show(); + return; + } + + showLoading(true); + authManager.isUsernameUnique(username, isUnique -> { + if (!isUnique) { + showLoading(false); + Toast.makeText(this, "Username is already taken", Toast.LENGTH_SHORT).show(); + return; + } + authManager.signup(displayName, username, email, password) + .addOnCompleteListener(task -> { + showLoading(false); + if (task.isSuccessful()) { + startMainActivity(); + } else { + String error = task.getException() != null ? + task.getException().getMessage() : + "Signup failed"; + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); + } + }); + }); + } + + private void startMainActivity() { + Intent intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } + + private void showLoading(boolean show) { + binding.progressBar.setVisibility(show ? View.VISIBLE : View.GONE); + binding.signupButton.setEnabled(!show); + binding.loginButton.setEnabled(!show); + binding.displayNameEditText.setEnabled(!show); + binding.emailEditText.setEnabled(!show); + binding.passwordEditText.setEnabled(!show); + } +} diff --git a/app/src/main/java/com/pineapple/capture/auth/AuthActivity.java b/app/src/main/java/com/pineapple/capture/auth/AuthActivity.java new file mode 100644 index 0000000..f58ae3c --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/auth/AuthActivity.java @@ -0,0 +1,132 @@ +package com.pineapple.capture.auth; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; +import android.util.Patterns; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputEditText; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.pineapple.capture.MainActivity; +import com.pineapple.capture.R; + + +public class AuthActivity extends AppCompatActivity { + private AuthViewModel authViewModel; + private TextInputEditText usernameInput; + private TextInputEditText passwordInput; + private MaterialButton loginButton; + private MaterialButton signupButton; + private TextView errorText; + private TextView resetPasswordLink; + private FirebaseAuth mAuth; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_auth); + + authViewModel = new ViewModelProvider(this).get(AuthViewModel.class); + + usernameInput = findViewById(R.id.username_input); + passwordInput = findViewById(R.id.password_input); + loginButton = findViewById(R.id.login_button); + signupButton = findViewById(R.id.signup_button); + errorText = findViewById(R.id.error_text); + resetPasswordLink = findViewById(R.id.reset_password_link); + + loginButton.setOnClickListener(v -> handleLogin()); + signupButton.setOnClickListener(v -> handleSignup()); + resetPasswordLink.setOnClickListener(v -> handleResetPassword()); + + authViewModel.getAuthState().observe(this, isAuthenticated -> { + if (isAuthenticated) { + startActivity(new Intent(this, MainActivity.class)); + finish(); + } + }); + + authViewModel.getErrorMessage().observe(this, error -> { + if (error != null && !error.isEmpty()) { + errorText.setText(error); + errorText.setVisibility(View.VISIBLE); + } else { + errorText.setVisibility(View.GONE); + } + }); + + mAuth = FirebaseAuth.getInstance(); + } + + @Override + protected void onStart() { + super.onStart(); + FirebaseUser currentUser = mAuth.getCurrentUser(); + if (currentUser != null) { + startActivity(new Intent(this, MainActivity.class)); + finish(); + } + } + + private void handleLogin() { + String username = usernameInput.getText().toString().trim(); + String password = passwordInput.getText().toString().trim(); + + if (validateInput(username, password)) { + authViewModel.signIn(username + "@pineapple.com", password); + } + } + + private void handleSignup() { + String username = usernameInput.getText().toString().trim(); + String password = passwordInput.getText().toString().trim(); + + if (validateInput(username, password)) { + authViewModel.signUp(username + "@pineapple.com", password); + } + } + + private void handleResetPassword() { + String email = usernameInput.getText().toString().trim(); + + if (email.isEmpty()) { + errorText.setText("Please enter your email"); + errorText.setVisibility(View.VISIBLE); + } else if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + errorText.setText("Invalid email format"); + errorText.setVisibility(View.VISIBLE); + } else { + authViewModel.resetPassword(email); + } + + } + + + private boolean validateInput(String username, String password) { + if (username.isEmpty()) { + errorText.setText("Username cannot be empty"); + errorText.setVisibility(View.VISIBLE); + return false; + } + + if (password.isEmpty()) { + errorText.setText("Password cannot be empty"); + errorText.setVisibility(View.VISIBLE); + return false; + } + + if (password.length() < 6) { + errorText.setText("Password must be at least 6 characters"); + errorText.setVisibility(View.VISIBLE); + return false; + } + + errorText.setVisibility(View.GONE); + return true; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/auth/AuthManager.java b/app/src/main/java/com/pineapple/capture/auth/AuthManager.java new file mode 100644 index 0000000..15414e9 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/auth/AuthManager.java @@ -0,0 +1,87 @@ +package com.pineapple.capture.auth; + +import com.google.android.gms.tasks.Task; +import com.google.firebase.auth.AuthResult; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.google.firebase.auth.UserProfileChangeRequest; +import com.google.firebase.firestore.FirebaseFirestore; +import com.pineapple.capture.models.User; + +public class AuthManager { + private static AuthManager instance; + private final FirebaseAuth auth; + private final FirebaseFirestore db; + private static final String USERS_COLLECTION = "users"; + + private AuthManager() { + auth = FirebaseAuth.getInstance(); + db = FirebaseFirestore.getInstance(); + } + + public static synchronized AuthManager getInstance() { + if (instance == null) { + instance = new AuthManager(); + } + return instance; + } + + public interface UsernameUniqueCallback { + void onResult(boolean isUnique); + } + + public void isUsernameUnique(String username, UsernameUniqueCallback callback) { + db.collection(USERS_COLLECTION) + .whereEqualTo("username", username) + .get() + .addOnSuccessListener(queryDocumentSnapshots -> { + callback.onResult(queryDocumentSnapshots.isEmpty()); + }) + .addOnFailureListener(e -> { + // On failure, treat as not unique to be safe + callback.onResult(false); + }); + } + + // Updated signup to accept username + public Task signup(String displayName, String username, String email, String password) { + return auth.createUserWithEmailAndPassword(email, password) + .addOnSuccessListener(authResult -> { + FirebaseUser firebaseUser = authResult.getUser(); + if (firebaseUser != null) { + UserProfileChangeRequest profileUpdates = new UserProfileChangeRequest.Builder() + .setDisplayName(displayName) + .build(); + firebaseUser.updateProfile(profileUpdates); + + User user = new User( + firebaseUser.getUid(), + username, + email, + null, + displayName, + "", + "" + ); + db.collection(USERS_COLLECTION) + .document(firebaseUser.getUid()) + .set(user); + } + }); + } + + public Task login(String email, String password) { + return auth.signInWithEmailAndPassword(email, password); + } + + + public FirebaseUser getCurrentUser() { + return auth.getCurrentUser(); + } + + + public Task sendPasswordResetEmail(String email) { + return auth.sendPasswordResetEmail(email); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/auth/AuthViewModel.java b/app/src/main/java/com/pineapple/capture/auth/AuthViewModel.java new file mode 100644 index 0000000..3938587 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/auth/AuthViewModel.java @@ -0,0 +1,98 @@ +package com.pineapple.capture.auth; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.google.firebase.firestore.FirebaseFirestore; +import com.pineapple.capture.profile.UserProfile; + +public class AuthViewModel extends ViewModel { + private FirebaseAuth auth; + private FirebaseFirestore db; + private MutableLiveData authState; + private MutableLiveData errorMessage; + + public AuthViewModel() { + auth = FirebaseAuth.getInstance(); + db = FirebaseFirestore.getInstance(); + authState = new MutableLiveData<>(); + errorMessage = new MutableLiveData<>(); + } + + public void signIn(String email, String password) { + auth.signInWithEmailAndPassword(email, password) + .addOnSuccessListener(authResult -> authState.setValue(true)) + .addOnFailureListener(e -> errorMessage.setValue("Invalid username or password")); + } + + public void signUp(String email, String password) { + String username = email.replace("@pineapple.com", ""); + + // Check if username already exists + db.collection("usernames") + .document(username) + .get() + .addOnSuccessListener(document -> { + if (document.exists()) { + errorMessage.setValue("Username already taken"); + } else { + auth.createUserWithEmailAndPassword(email, password) + .addOnSuccessListener(authResult -> { + FirebaseUser user = authResult.getUser(); + if (user != null) { + // user profile + UserProfile profile = new UserProfile(username, ""); + db.collection("users") + .document(user.getUid()) + .set(profile) + .addOnSuccessListener(aVoid -> { + // Reserve username + db.collection("usernames") + .document(username) + .set(new UsernameReservation(user.getUid())) + .addOnSuccessListener(aVoid2 -> authState.setValue(true)) + .addOnFailureListener(e -> errorMessage.setValue("Failed to create user profile")); + }) + .addOnFailureListener(e -> errorMessage.setValue("Failed to create user profile")); + } + }) + .addOnFailureListener(e -> errorMessage.setValue("Failed to create account")); + } + }) + .addOnFailureListener(e -> errorMessage.setValue("Failed to check username availability")); + } + + public void resetPassword(String email) { + auth.sendPasswordResetEmail(email) + .addOnSuccessListener(aVoid -> authState.setValue(true)) + .addOnFailureListener(e -> errorMessage.setValue("Failed to send reset email")); + } + + public LiveData getAuthState() { + return authState; + } + + public LiveData getErrorMessage() { + return errorMessage; + } + + public FirebaseUser getCurrentUser() { + return auth.getCurrentUser(); + } + + private static class UsernameReservation { + private String userId; + + public UsernameReservation() {} + + public UsernameReservation(String userId) { + this.userId = userId; + } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/feed/FeedItem.java b/app/src/main/java/com/pineapple/capture/feed/FeedItem.java new file mode 100644 index 0000000..bd444fb --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/feed/FeedItem.java @@ -0,0 +1,175 @@ +package com.pineapple.capture.feed; + +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.Exclude; +import com.google.firebase.firestore.ServerTimestamp; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FeedItem { + private String id; + private String userId; + private String content; + private String imageUrl; + + @ServerTimestamp + private Timestamp timestamp; + + private int likes; + private List likedBy; + private List> comments; + private String profilePictureUrl; + private String username; + + @Exclude + private boolean isLikedByCurrentUser; + + public FeedItem() { + this.likes = 0; + this.likedBy = new ArrayList<>(); + this.comments = new ArrayList<>(); + this.isLikedByCurrentUser = false; + } + + public FeedItem(String userId, String content, String imageUrl, String profilePictureUrl, String username) { + this.userId = userId; + this.content = content; + this.imageUrl = imageUrl; + this.timestamp = Timestamp.now(); + this.likes = 0; + this.likedBy = new ArrayList<>(); + this.comments = new ArrayList<>(); + this.profilePictureUrl = profilePictureUrl; + this.username = username; + this.isLikedByCurrentUser = false; + } + + // Convert to Firestore Map for easier saving + public Map toMap() { + Map map = new HashMap<>(); + map.put("userId", userId); + map.put("content", content); + map.put("imageUrl", imageUrl); + map.put("timestamp", Timestamp.now()); // Use current timestamp as server timestamp may not be applied yet + map.put("likes", likes); + map.put("likedBy", likedBy); + map.put("comments", comments); + map.put("profilePictureUrl", profilePictureUrl); + map.put("username", username); + return map; + } + + public void addComment(String userId, String username, String text) { + if (comments == null) { + comments = new ArrayList<>(); + } + + Map comment = new HashMap<>(); + comment.put("userId", userId); + comment.put("username", username); + comment.put("text", text); + comment.put("timestamp", Timestamp.now()); + + comments.add(comment); + } + + public boolean toggleLike(String userId) { + if (likedBy == null) { + likedBy = new ArrayList<>(); + } + + if (likedBy.contains(userId)) { + // User already liked, so unlike + likedBy.remove(userId); + likes = Math.max(0, likes - 1); + isLikedByCurrentUser = false; + return false; + } else { + likedBy.add(userId); + likes += 1; + isLikedByCurrentUser = true; + return true; + } + } + + public boolean isLikedBy(String userId) { + return likedBy != null && likedBy.contains(userId); + } + + // Getters and setters + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public Timestamp getTimestamp() { + if (timestamp == null) { + timestamp = Timestamp.now(); + } + return timestamp; + } + + public void setTimestamp(Timestamp timestamp) { this.timestamp = timestamp; } + + public Date getTimestampAsDate() { + if (timestamp == null) { + return new Date(); + } + return timestamp.toDate(); + } + + public int getLikes() { return likes; } + public void setLikes(int likes) { this.likes = likes; } + + public List getLikedBy() { return likedBy; } + public void setLikedBy(List likedBy) { this.likedBy = likedBy; } + + public List> getComments() { return comments; } + public void setComments(List> comments) { this.comments = comments; } + + @Exclude + public boolean isLikedByCurrentUser() { return isLikedByCurrentUser; } + + @Exclude + public void setLikedByCurrentUser(boolean likedByCurrentUser) { this.isLikedByCurrentUser = likedByCurrentUser; } + + public String getProfilePictureUrl() { + return profilePictureUrl; + } + + public void setProfilePictureUrl(String profilePictureUrl) { + this.profilePictureUrl = profilePictureUrl; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + @Override + public String toString() { + return "FeedItem{" + + "id='" + id + '\'' + + ", userId='" + userId + '\'' + + ", content='" + content + '\'' + + ", imageUrl='" + imageUrl + '\'' + + ", timestamp=" + timestamp + + ", likes=" + likes + + ", comments=" + (comments != null ? comments.size() : 0) + + ", username='" + username + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/feed/MainFeedViewModel.java b/app/src/main/java/com/pineapple/capture/feed/MainFeedViewModel.java new file mode 100644 index 0000000..e5a9a0c --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/feed/MainFeedViewModel.java @@ -0,0 +1,44 @@ +package com.pineapple.capture.feed; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import com.google.firebase.firestore.FirebaseFirestore; +import java.util.List; +import java.util.ArrayList; + +public class MainFeedViewModel extends ViewModel { + private FirebaseFirestore db; + private MutableLiveData> feedItems; + + public MainFeedViewModel() { + db = FirebaseFirestore.getInstance(); + feedItems = new MutableLiveData<>(new ArrayList<>()); + loadFeedItems(); + } + + private void loadFeedItems() { + db.collection("posts") + .orderBy("timestamp", com.google.firebase.firestore.Query.Direction.DESCENDING) + .addSnapshotListener((value, error) -> { + if (error != null) { + return; + } + + List items = new ArrayList<>(); + if (value != null) { + for (com.google.firebase.firestore.DocumentSnapshot doc : value) { + FeedItem item = doc.toObject(FeedItem.class); + if (item != null) { + items.add(item); + } + } + } + feedItems.setValue(items); + }); + } + + public LiveData> getFeedItems() { + return feedItems; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/fragment/CameraFragment.java b/app/src/main/java/com/pineapple/capture/fragment/CameraFragment.java new file mode 100644 index 0000000..fb8a416 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/fragment/CameraFragment.java @@ -0,0 +1,495 @@ +package com.pineapple.capture.fragment; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.Bundle; +import android.Manifest; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCaptureException; +import androidx.camera.core.Preview; +import androidx.camera.core.AspectRatio; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.view.PreviewView; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.textfield.TextInputEditText; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.firestore.FirebaseFirestore; +import com.pineapple.capture.R; +import com.pineapple.capture.utils.CloudinaryManager; +import com.cloudinary.android.callback.UploadCallback; +import com.cloudinary.android.callback.ErrorInfo; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; + +public class CameraFragment extends Fragment { + + private PreviewView previewView; + private ImageCapture imageCapture; + private boolean flashEnabled = false; + private ImageButton toggleFlashButton; + private boolean isUsingFrontCamera = false; + private File capturedImageFile; + private ImageView capturedImageView; + + private TextInputEditText captionInput; + private Button postButton; + private Button cancelButton; + private View buttonContainer; + private com.google.android.material.textfield.TextInputLayout captionLayout; + + private static final int MAX_WORD_COUNT = 50; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_camera, container, false); + + previewView = view.findViewById(R.id.previewView); + captionInput = view.findViewById(R.id.caption_input); + postButton = view.findViewById(R.id.post_button); + capturedImageView = view.findViewById(R.id.captured_image_view); + buttonContainer = view.findViewById(R.id.button_container); + cancelButton = view.findViewById(R.id.cancel_button); + captionLayout = view.findViewById(R.id.caption_layout); + + // Initially hide the post controls + buttonContainer.setVisibility(View.GONE); + captionLayout.setVisibility(View.GONE); + + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, 100); + } + + ImageButton switchCameraButton = view.findViewById(R.id.switch_camera_button); + switchCameraButton.setOnClickListener(v -> { + isUsingFrontCamera = !isUsingFrontCamera; + startCamera(); + }); + + cancelButton.setOnClickListener(v -> { + if (capturedImageFile != null) { + resetCameraState(); + } + }); + + ImageButton captureButton = view.findViewById(R.id.capture_button); + captureButton.setOnClickListener(v -> captureImage()); + + toggleFlashButton = view.findViewById(R.id.toggle_flash); + toggleFlashButton.setOnClickListener(v -> { + flashEnabled = !flashEnabled; + updateFlashIconColor(); + startCamera(); + }); + + postButton.setOnClickListener(v -> postToFeed()); + + // Add text watcher to limit caption to 50 words + captionInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + // Count words + String input = s.toString().trim(); + if (input.isEmpty()) return; + + String[] words = input.split("\\s+"); + if (words.length > MAX_WORD_COUNT) { + // Truncate to max word count + StringBuilder truncated = new StringBuilder(); + for (int i = 0; i < MAX_WORD_COUNT; i++) { + truncated.append(words[i]).append(" "); + } + captionInput.setText(truncated.toString().trim()); + captionInput.setSelection(truncated.toString().trim().length()); + + Toast.makeText(requireContext(), "Caption limited to 50 words", Toast.LENGTH_SHORT).show(); + } + + // Update caption counter if needed + captionLayout.setHelperText(words.length + "/" + MAX_WORD_COUNT + " words"); + } + }); + + startCamera(); + + return view; + } + + private void startCamera() { + ListenableFuture cameraProviderFuture = + ProcessCameraProvider.getInstance(requireContext()); + + cameraProviderFuture.addListener(() -> { + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + + CameraSelector cameraSelector = new CameraSelector.Builder() + .requireLensFacing(isUsingFrontCamera + ? CameraSelector.LENS_FACING_FRONT + : CameraSelector.LENS_FACING_BACK) + .build(); + + Preview preview = new Preview.Builder() + .build(); + preview.setSurfaceProvider(previewView.getSurfaceProvider()); + + // Configure capture with square aspect ratio + imageCapture = new ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .setTargetAspectRatio(AspectRatio.RATIO_4_3) // Set a 4:3 aspect ratio for photos + .setFlashMode(flashEnabled ? ImageCapture.FLASH_MODE_ON : ImageCapture.FLASH_MODE_OFF) + .build(); + + cameraProvider.unbindAll(); + cameraProvider.bindToLifecycle( + getViewLifecycleOwner(), cameraSelector, preview, imageCapture + ); + + } catch (ExecutionException | InterruptedException e) { + Log.e("CameraX", "Failed to start camera", e); + } + }, ContextCompat.getMainExecutor(requireContext())); + } + + private void updateFlashIconColor() { + int color = flashEnabled + ? ContextCompat.getColor(requireContext(), android.R.color.white) + : ContextCompat.getColor(requireContext(), R.color.primary_blue); + + toggleFlashButton.setColorFilter(color); + } + + private void resetCameraState() { + capturedImageFile = null; + + buttonContainer.setVisibility(View.GONE); + captionLayout.setVisibility(View.GONE); + + capturedImageView.setVisibility(View.GONE); + previewView.setVisibility(View.VISIBLE); + findViewById(R.id.capture_button).setVisibility(View.VISIBLE); + findViewById(R.id.switch_camera_button).setVisibility(View.VISIBLE); + findViewById(R.id.toggle_flash).setVisibility(View.VISIBLE); + + startCamera(); + } + + private void captureImage() { + if (imageCapture == null) return; + + capturedImageFile = new File(requireContext().getExternalFilesDir(null), + new SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault()) + .format(new Date()) + ".jpg"); + + ImageCapture.OutputFileOptions outputFileOptions = + new ImageCapture.OutputFileOptions.Builder(capturedImageFile).build(); + + imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(requireContext()), + new ImageCapture.OnImageSavedCallback() { + @Override + public void onImageSaved(@NonNull ImageCapture.OutputFileResults output) { + Log.d("CameraX", "Image saved: " + capturedImageFile.getAbsolutePath()); + + previewView.setVisibility(View.GONE); + capturedImageView.setVisibility(View.VISIBLE); + + // Hide camera controls when showing captured image + findViewById(R.id.capture_button).setVisibility(View.GONE); + findViewById(R.id.switch_camera_button).setVisibility(View.GONE); + findViewById(R.id.toggle_flash).setVisibility(View.GONE); + + // Try to keep the true color of the picture + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + + // Handle rotating picture + Bitmap bitmap = BitmapFactory.decodeFile(capturedImageFile.getAbsolutePath()); + try { + ExifInterface exif = new ExifInterface(capturedImageFile.getAbsolutePath()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + int rotationInDegrees = 0; + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + rotationInDegrees = 90; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + rotationInDegrees = 180; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + rotationInDegrees = 270; + break; + } + if (rotationInDegrees != 0) { + Matrix matrix = new Matrix(); + matrix.postRotate(rotationInDegrees); + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + } + } catch (IOException e) { + e.printStackTrace(); + } + + capturedImageView.setImageBitmap(bitmap); + + buttonContainer.setVisibility(View.VISIBLE); + captionLayout.setVisibility(View.VISIBLE); + + captionInput.setText(""); + } + + @Override + public void onError(@NonNull ImageCaptureException exception) { + Log.e("CameraX", "Failed to capture image", exception); + Toast.makeText(requireContext(), "Failed to capture image", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void postToFeed() { + if (capturedImageFile == null) { + Toast.makeText(requireContext(), "Please capture an image first", Toast.LENGTH_SHORT).show(); + return; + } + + String caption = captionInput.getText().toString().trim(); + + postButton.setEnabled(false); + cancelButton.setEnabled(false); + postButton.setText("Posting..."); + + CloudinaryManager.init(requireContext()); + + CloudinaryManager.uploadImage(Uri.fromFile(capturedImageFile), new UploadCallback() { + @Override + public void onStart(String requestId) { + Toast.makeText(requireContext(), "Uploading image...", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onProgress(String requestId, long bytes, long totalBytes) { + } + + @Override + public void onSuccess(String requestId, Map resultData) { + String imageUrl = CloudinaryManager.getImageUrl(resultData); + Log.d("CameraFragment", "Cloudinary upload successful, image URL: " + imageUrl); + savePostToFirestore(imageUrl, caption); + } + + @Override + public void onError(String requestId, ErrorInfo error) { + postButton.setEnabled(true); + postButton.setText("Post"); + Toast.makeText(requireContext(), "Error uploading image: " + error.getDescription(), Toast.LENGTH_SHORT).show(); + } + + @Override + public void onReschedule(String requestId, ErrorInfo error) { + Toast.makeText(requireContext(), "Upload rescheduled", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void savePostToFirestore(String imageUrl, String content) { + if (imageUrl == null || imageUrl.isEmpty()) { + Log.e("CameraFragment", "Cannot save post with empty imageUrl"); + Toast.makeText(requireContext(), "Error: No image URL received", Toast.LENGTH_SHORT).show(); + postButton.setEnabled(true); + postButton.setText("Post"); + return; + } + + String userId = FirebaseAuth.getInstance().getCurrentUser().getUid(); + Log.d("CameraFragment", "Creating post for user: " + userId); + + FirebaseFirestore.getInstance().collection("users").document(userId) + .get() + .addOnSuccessListener(documentSnapshot -> { + if (documentSnapshot.exists()) { + List profilePictureUrls = (List) documentSnapshot.get("profilePictureUrl"); + String profilePictureUrl = ""; + + if (profilePictureUrls != null && !profilePictureUrls.isEmpty()) { + profilePictureUrl = profilePictureUrls.get(0); + } + + String username = documentSnapshot.getString("username"); + + if (username == null || username.isEmpty()) { + username = "Anonymous"; + } + + // Log data for debugging + Log.d("CameraFragment", "Saving post with data:"); + Log.d("CameraFragment", " userId: " + userId); + Log.d("CameraFragment", " content: " + content); + Log.d("CameraFragment", " imageUrl: " + imageUrl); + Log.d("CameraFragment", " username: " + username); + Log.d("CameraFragment", " profilePictureUrl: " + profilePictureUrl); + + // Create a manual map of post data + Map postData = new HashMap<>(); + postData.put("userId", userId); + postData.put("content", content); + postData.put("imageUrl", imageUrl); + postData.put("timestamp", com.google.firebase.Timestamp.now()); + postData.put("likes", 0); + postData.put("profilePictureUrl", profilePictureUrl); + postData.put("username", username); + + FirebaseFirestore.getInstance().collection("posts") + .add(postData) + .addOnSuccessListener(documentReference -> { + // Success - post created and image uploaded + String postId = documentReference.getId(); + Log.d("CameraFragment", "Post saved with ID: " + postId); + Toast.makeText(requireContext(), "Post uploaded successfully!", Toast.LENGTH_SHORT).show(); + + // Update the home screen widget + updateWidget(); + + // For testing: verify the post was saved by reading it back + FirebaseFirestore.getInstance().collection("posts").document(postId) + .get() + .addOnSuccessListener(postSnapshot -> { + if (postSnapshot.exists()) { + Log.d("CameraFragment", "Verification - Post was saved successfully: " + postSnapshot.getData()); + } else { + Log.w("CameraFragment", "Verification - Post was not found after saving"); + } + + resetCameraAfterPost(); + }) + .addOnFailureListener(e -> { + Log.w("CameraFragment", "Verification - Failed to verify post save: " + e.getMessage()); + resetCameraAfterPost(); + }); + }) + .addOnFailureListener(e -> { + Log.e("CameraFragment", "Error saving post: " + e.getMessage(), e); + postButton.setEnabled(true); + cancelButton.setEnabled(true); + postButton.setText("Post"); + Toast.makeText(requireContext(), "Error saving post: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + }); + } else { + Log.e("CameraFragment", "User profile not found"); + Toast.makeText(requireContext(), "Error: User profile not found", Toast.LENGTH_SHORT).show(); + postButton.setEnabled(true); + cancelButton.setEnabled(true); + postButton.setText("Post"); + } + }) + .addOnFailureListener(e -> { + Log.e("CameraFragment", "Error fetching user profile: " + e.getMessage()); + postButton.setEnabled(true); + cancelButton.setEnabled(true); + postButton.setText("Post"); + Toast.makeText(requireContext(), "Error fetching user profile: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + }); + } + + private View findViewById(int id) { + return requireView().findViewById(id); + } + + private void resetCameraAfterPost() { + capturedImageFile = null; + + buttonContainer.setVisibility(View.GONE); + captionLayout.setVisibility(View.GONE); + + capturedImageView.setVisibility(View.GONE); + previewView.setVisibility(View.VISIBLE); + findViewById(R.id.capture_button).setVisibility(View.VISIBLE); + findViewById(R.id.switch_camera_button).setVisibility(View.VISIBLE); + findViewById(R.id.toggle_flash).setVisibility(View.VISIBLE); + + postButton.setEnabled(true); + cancelButton.setEnabled(true); + postButton.setText("Post"); + + Log.d("CameraFragment", "Post successful, returning to home tab"); + if (getActivity() != null) { + try { + // 1. Load the HomeFragment + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + fragmentManager.beginTransaction() + .replace(R.id.fragment_container, new HomeFragment()) + .commit(); + + // 2. Select the home tab in the bottom navigation + BottomNavigationView bottomNav = getActivity().findViewById(R.id.bottomNavigation); + if (bottomNav != null) { + bottomNav.setSelectedItemId(R.id.navigation_home); + } + } catch (Exception e) { + Log.e("CameraFragment", "Error navigating back to home tab: " + e.getMessage(), e); + getActivity().finish(); + } + } + } + + /** + * Update the home screen widget with the latest post + */ + private void updateWidget() { + Intent intent = new Intent(requireContext(), com.pineapple.capture.widget.LatestPostWidget.class); + intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); + + // Use AppWidgetManager to get the widget IDs + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(requireContext()); + int[] appWidgetIds = appWidgetManager.getAppWidgetIds( + new ComponentName(requireContext(), com.pineapple.capture.widget.LatestPostWidget.class)); + + // Add widget IDs to the intent + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds); + + // Send broadcast to update widgets + requireContext().sendBroadcast(intent); + Log.d("CameraFragment", "Widget update broadcast sent"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/fragment/HomeFragment.java b/app/src/main/java/com/pineapple/capture/fragment/HomeFragment.java new file mode 100644 index 0000000..9932cb1 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/fragment/HomeFragment.java @@ -0,0 +1,734 @@ +package com.pineapple.capture.fragment; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.load.resource.bitmap.RoundedCorners; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.request.target.Target; +import com.google.firebase.Timestamp; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.Query; +import com.google.firebase.firestore.QueryDocumentSnapshot; +import com.google.firebase.firestore.DocumentSnapshot; +import com.pineapple.capture.R; +import com.pineapple.capture.databinding.FragmentHomeBinding; +import com.pineapple.capture.feed.FeedItem; +import com.pineapple.capture.profile.ProfileViewModel; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class HomeFragment extends Fragment implements OnDeleteClickListener { + private FragmentHomeBinding binding; + private ProfileViewModel profileViewModel; + private RecyclerView feedRecyclerView; + private FeedAdapter feedAdapter; + private List feedItems; + private SwipeRefreshLayout swipeRefreshLayout; + private View emptyStateLayout; + private FirebaseFirestore db; + private FirebaseAuth mAuth; + + public HomeFragment() {} + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = FragmentHomeBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + feedRecyclerView = root.findViewById(R.id.feed_recycler_view); + swipeRefreshLayout = root.findViewById(R.id.swipe_refresh_layout); + emptyStateLayout = root.findViewById(R.id.empty_state_layout); + + feedItems = new ArrayList<>(); + + feedRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + feedAdapter = new FeedAdapter(feedItems, this); + feedAdapter.setHasStableIds(true); // Improve RecyclerView performance + feedRecyclerView.setAdapter(feedAdapter); + + db = FirebaseFirestore.getInstance(); + mAuth = FirebaseAuth.getInstance(); + + swipeRefreshLayout.setOnRefreshListener(this::loadFeedPosts); + swipeRefreshLayout.setColorSchemeResources(R.color.primary_blue); + + loadFeedPosts(); + + return root; + } + + @Override + public void onDeleteClick(String postId) { + deletePost(postId); // Gọi hàm xoá + } + + + @Override + public void onResume() { + super.onResume(); + Log.d("HomeFragment", "onResume - reloading posts"); + loadFeedPosts(); + } + + public void loadFeedPosts() { + if (getActivity() == null) return; + + Log.d("HomeFragment", "Loading feed posts..."); + swipeRefreshLayout.setRefreshing(true); + + String currentUserId = mAuth.getCurrentUser() != null ? mAuth.getCurrentUser().getUid() : null; + + db.collection("posts").get() + .addOnSuccessListener(snapshot -> { + int count = snapshot.size(); + Log.d("HomeFragment", "Found " + count + " posts in collection"); + + if (count > 0) { + // Log each post for debugging + for (QueryDocumentSnapshot document : snapshot) { + Log.d("HomeFragment", "Post: id=" + document.getId() + + ", content=" + document.getString("content") + + ", imageUrl=" + document.getString("imageUrl") + + ", username=" + document.getString("username")); + } + } + }) + .addOnFailureListener(e -> Log.e("HomeFragment", "Error checking post count", e)); + + db.collection("posts") + .orderBy("timestamp", Query.Direction.DESCENDING) + .get() + .addOnSuccessListener(queryDocumentSnapshots -> { + Log.d("HomeFragment", "Query returned " + queryDocumentSnapshots.size() + " documents"); + + feedItems.clear(); + Log.d("HomeFragment", "Cleared previous feedItems. Size now: " + feedItems.size()); + + for (QueryDocumentSnapshot document : queryDocumentSnapshots) { + try { + // Log raw document data + Log.d("HomeFragment", "Processing document: " + document.getId()); + Log.d("HomeFragment", "Document data: " + document.getData()); + + FeedItem item = document.toObject(FeedItem.class); + + // Check for null values + if (item == null) { + Log.e("HomeFragment", "Failed to convert document to FeedItem: " + document.getId()); + continue; + } + + item.setId(document.getId()); + + if (currentUserId != null && item.isLikedBy(currentUserId)) { + item.setLikedByCurrentUser(true); + } + + // Debug log all properties + Log.d("HomeFragment", "FeedItem: id=" + item.getId() + + ", content=" + item.getContent() + + ", imageUrl=" + item.getImageUrl() + + ", username=" + item.getUsername() + + ", likes=" + item.getLikes() + + ", liked by current user=" + item.isLikedByCurrentUser() + + ", timestamp=" + (item.getTimestamp() != null ? item.getTimestamp().toDate() : "null")); + + if (item.getImageUrl() != null && !item.getImageUrl().isEmpty()) { + feedItems.add(item); + Log.d("HomeFragment", "Added post to feedItems. New size: " + feedItems.size()); + } else { + Log.w("HomeFragment", "Skipping post with empty image URL: " + item.getId()); + } + } catch (Exception e) { + Log.e("HomeFragment", "Error processing document: " + document.getId(), e); + } + } + + Log.d("HomeFragment", "Processed " + feedItems.size() + " posts"); + Log.d("HomeFragment", "Showing " + feedItems.size() + " posts"); + + // Update UI + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + Log.d("HomeFragment", "Notifying adapter of data changes. Items count: " + feedItems.size()); + feedAdapter.notifyDataSetChanged(); + swipeRefreshLayout.setRefreshing(false); + updateEmptyState(); + + // Check empty state + boolean isEmpty = feedItems == null || feedItems.isEmpty(); + Log.d("HomeFragment", "Feed is empty: " + isEmpty); + Log.d("HomeFragment", "EmptyStateLayout visibility: " + + (emptyStateLayout.getVisibility() == View.VISIBLE ? "VISIBLE" : "GONE")); + Log.d("HomeFragment", "RecyclerView visibility: " + + (feedRecyclerView.getVisibility() == View.VISIBLE ? "VISIBLE" : "GONE")); + }); + } + }) + .addOnFailureListener(e -> { + String errorMsg = e.getMessage(); + Log.e("HomeFragment", "Error loading posts: " + errorMsg, e); + + if (errorMsg != null && errorMsg.contains("UNAVAILABLE")) { + Log.w("HomeFragment", "Network appears to be unavailable, retrying in 3 seconds"); + + new Handler(Looper.getMainLooper()).postDelayed( + this::loadFeedPosts, + 3000 + ); + } else { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + swipeRefreshLayout.setRefreshing(false); + Toast.makeText(getContext(), "Error loading posts: " + errorMsg, Toast.LENGTH_SHORT).show(); + updateEmptyState(); + }); + } + } + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + + // Debug method to check Firestore data + private void checkPostsData(String specificPostId) { + Log.d("HomeFragment", "DEBUGGING: Checking posts collection directly"); + + // Fetch all posts + db.collection("posts") + .get() + .addOnSuccessListener(querySnapshot -> { + Log.d("HomeFragment", "DEBUG: Total posts in Firestore: " + querySnapshot.size()); + + // Look for the specific post + boolean foundSpecificPost = false; + + for (DocumentSnapshot doc : querySnapshot) { + Map data = doc.getData(); + + Log.d("HomeFragment", "DEBUG: Post " + doc.getId() + " data: " + data); + + if (doc.getId().equals(specificPostId)) { + foundSpecificPost = true; + Log.d("HomeFragment", "DEBUG: Found the specific post: " + specificPostId); + Log.d("HomeFragment", "DEBUG: Post data: " + data); + + String imageUrl = (String) data.get("imageUrl"); + String content = (String) data.get("content"); + String username = (String) data.get("username"); + Timestamp timestamp = (Timestamp) data.get("timestamp"); + + Log.d("HomeFragment", "DEBUG: imageUrl = " + imageUrl); + Log.d("HomeFragment", "DEBUG: content = " + content); + Log.d("HomeFragment", "DEBUG: username = " + username); + Log.d("HomeFragment", "DEBUG: timestamp = " + (timestamp != null ? timestamp.toDate() : "null")); + + // Check if image URL is valid + if (imageUrl != null && !imageUrl.isEmpty()) { + Log.d("HomeFragment", "DEBUG: Image URL is valid"); + } else { + Log.e("HomeFragment", "DEBUG: Image URL is invalid: " + imageUrl); + } + } + } + + if (!foundSpecificPost) { + Log.e("HomeFragment", "DEBUG: Couldn't find the specific post: " + specificPostId); + } + }) + .addOnFailureListener(e -> { + Log.e("HomeFragment", "DEBUG: Error fetching posts", e); + }); + } + + private void deletePost(String postId) { + swipeRefreshLayout.setRefreshing(true); + + new AlertDialog.Builder(requireContext()) + .setTitle("Delete Post") + .setMessage("Are you sure you want to delete this post? This action cannot be undone.") + .setPositiveButton("Delete", (dialog, which) -> { + Log.d("HomeFragment", "Deleting..."); + + db.collection("posts") + .document(postId) + .delete() + .addOnSuccessListener(aVoid -> { + Log.d("HomeFragment", "Successfully deleted post: " + postId); + Toast.makeText(requireContext(), "Post deleted", Toast.LENGTH_SHORT).show(); + removePostFromList(postId); + swipeRefreshLayout.setRefreshing(false); + updateEmptyState(); + + }) + .addOnFailureListener(e -> { + Log.e("HomeFragment", "Error getting posts to delete", e); + Toast.makeText(requireContext(), "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + swipeRefreshLayout.setRefreshing(false); + }); + }) + .setNegativeButton("Cancel", (dialog, which) -> { + swipeRefreshLayout.setRefreshing(false); + }) + .show(); + } + + private void removePostFromList(String postId) { + Iterator iterator = feedItems.iterator(); + while (iterator.hasNext()) { + FeedItem item = iterator.next(); + if (item.getId().equals(postId)) { + iterator.remove(); + feedAdapter.notifyDataSetChanged(); + break; + } + } + } + + private void handleLike(FeedItem post) { + if (mAuth.getCurrentUser() == null) { + Toast.makeText(getContext(), "You must be logged in to like posts", Toast.LENGTH_SHORT).show(); + return; + } + + String userId = mAuth.getCurrentUser().getUid(); + boolean isNowLiked = post.toggleLike(userId); + + // Update Firestore + db.collection("posts").document(post.getId()) + .update("likes", post.getLikes(), "likedBy", post.getLikedBy()) + .addOnSuccessListener(aVoid -> { + Log.d("HomeFragment", "Post " + (isNowLiked ? "liked" : "unliked") + " successfully"); + }) + .addOnFailureListener(e -> { + post.toggleLike(userId); // Toggle back + feedAdapter.notifyDataSetChanged(); + Toast.makeText(getContext(), "Failed to update like status", Toast.LENGTH_SHORT).show(); + Log.e("HomeFragment", "Error updating like status", e); + }); + + feedAdapter.notifyDataSetChanged(); + } + + // Method to share a post + private void sharePost(FeedItem post) { + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + + String shareText = "Check out this post from " + post.getUsername() + " on Capture!\n\n"; + if (post.getContent() != null && !post.getContent().isEmpty()) { + shareText += post.getContent() + "\n\n"; + } + + if (post.getImageUrl() != null && !post.getImageUrl().isEmpty()) { + shareText += "Image: " + post.getImageUrl(); + } + + shareText += "\n\nDownload Capture to see more!"; + + shareIntent.putExtra(Intent.EXTRA_TEXT, shareText); + startActivity(Intent.createChooser(shareIntent, "Share via")); + } + + private void showCommentDialog(FeedItem post) { + if (getContext() == null) return; + + AlertDialog.Builder builder = new AlertDialog.Builder(getContext(), R.style.AppTheme_AlertDialog); + builder.setTitle("Add a comment"); + + final EditText input = new EditText(getContext()); + input.setHint("Write your comment..."); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setTextColor(getResources().getColor(R.color.white)); + input.setHintTextColor(getResources().getColor(R.color.system_gray)); + + int paddingPx = (int) (16 * getResources().getDisplayMetrics().density); + input.setPadding(paddingPx, paddingPx, paddingPx, paddingPx); + + builder.setView(input); + + builder.setPositiveButton("Post", (dialog, which) -> { + String commentText = input.getText().toString().trim(); + if (!commentText.isEmpty() && mAuth.getCurrentUser() != null) { + addComment(post, commentText); + + for (int i = 0; i < feedItems.size(); i++) { + if (feedItems.get(i).getId().equals(post.getId())) { + feedAdapter.notifyItemChanged(i); + break; + } + } + } else { + Toast.makeText(getContext(), "Comment cannot be empty", Toast.LENGTH_SHORT).show(); + } + }); + + builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private void addComment(FeedItem post, String commentText) { + if (mAuth.getCurrentUser() == null) return; + + String userId = mAuth.getCurrentUser().getUid(); + String username = mAuth.getCurrentUser().getDisplayName(); + + post.addComment(userId, username, commentText); + + db.collection("posts").document(post.getId()) + .update("comments", post.getComments()) + .addOnSuccessListener(aVoid -> { + Log.d("HomeFragment", "Comment added successfully"); + Toast.makeText(getContext(), "Comment added", Toast.LENGTH_SHORT).show(); + feedAdapter.notifyDataSetChanged(); // Refresh UI + }) + .addOnFailureListener(e -> { + Log.e("HomeFragment", "Error adding comment", e); + Toast.makeText(getContext(), "Failed to add comment", Toast.LENGTH_SHORT).show(); + }); + } + + private static class FeedAdapter extends RecyclerView.Adapter { + private final List feedItems; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy 'at' h:mm a", Locale.getDefault()); + private final OnDeleteClickListener listener; + + + public FeedAdapter(List feedItems, OnDeleteClickListener listener) { + this.feedItems = feedItems; + this.listener = listener; + setHasStableIds(true); // Ensure stable IDs for better RecyclerView performance + } + + + + + @Override + public long getItemId(int position) { + // hash the post id + FeedItem item = feedItems.get(position); + return item.getId() != null ? item.getId().hashCode() : position; + } + + @NonNull + @Override + public FeedViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_feed_post, parent, false); + return new FeedViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull FeedViewHolder holder, int position) { + FeedItem post = feedItems.get(position); + holder.bind(post, dateFormat); + + holder.deletePostsButton.setOnClickListener(v -> { + if (listener != null && post.getId() != null) { + listener.onDeleteClick(post.getId()); + } + }); + } + + @Override + public int getItemCount() { + return feedItems.size(); + } + + static class FeedViewHolder extends RecyclerView.ViewHolder { + private final ImageView profileImage; + private final TextView usernameText; + private final TextView captionText; + private final ImageView postImage; + private final TextView timestampText; + private final TextView likesText; + private final ImageButton deletePostsButton; + private final ImageButton likeButton; + private final ImageButton commentButton; + private final ImageButton shareButton; + private final TextView commentsHeader; + private final LinearLayout commentsContainer; + private final TextView viewAllComments; + + public FeedViewHolder(@NonNull View itemView) { + super(itemView); + profileImage = itemView.findViewById(R.id.profile_image); + usernameText = itemView.findViewById(R.id.username_text); + captionText = itemView.findViewById(R.id.caption_text); + postImage = itemView.findViewById(R.id.post_image); + timestampText = itemView.findViewById(R.id.timestamp_text); + likesText = itemView.findViewById(R.id.likes_text); + deletePostsButton = itemView.findViewById(R.id.delete_button); + likeButton = itemView.findViewById(R.id.like_button); + commentButton = itemView.findViewById(R.id.comment_button); + shareButton = itemView.findViewById(R.id.share_button); + commentsHeader = itemView.findViewById(R.id.comments_header); + commentsContainer = itemView.findViewById(R.id.comments_container); + viewAllComments = itemView.findViewById(R.id.view_all_comments); + } + + public void bind(FeedItem post, SimpleDateFormat dateFormat) { + usernameText.setText(post.getUsername() != null ? post.getUsername() : "Anonymous"); + + + if (post.getContent() != null && !post.getContent().isEmpty()) { + captionText.setVisibility(View.VISIBLE); + captionText.setText(post.getContent()); + } else { + captionText.setVisibility(View.GONE); + } + + if (post.getTimestamp() != null) { + Timestamp timestamp = post.getTimestamp(); + Date date = timestamp.toDate(); + timestampText.setText(dateFormat.format(date)); + } else { + timestampText.setText("Just now"); + } + + likesText.setText(String.format(Locale.getDefault(), "%d likes", post.getLikes())); + + String profileUrl = post.getProfilePictureUrl(); + if (profileUrl != null && !profileUrl.isEmpty()) { + Glide.with(itemView.getContext()) + .load(profileUrl) + .placeholder(R.drawable.ic_person) + .error(R.drawable.ic_person) + .circleCrop() + .into(profileImage); + } else { + Glide.with(itemView.getContext()) + .load(R.drawable.ic_person) + .circleCrop() + .into(profileImage); + } + + if (post.getImageUrl() != null && !post.getImageUrl().isEmpty()) { + try { + String imageUrl = post.getImageUrl().trim(); + Log.d("FeedViewHolder", "Loading post image: " + imageUrl + " for post: " + post.getId()); + + if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { + imageUrl = "https://" + imageUrl; + Log.d("FeedViewHolder", "Fixed image URL to: " + imageUrl); + } + + final String finalImageUrl = imageUrl; + postImage.setVisibility(View.VISIBLE); + postImage.setBackgroundColor(itemView.getResources().getColor(R.color.system_gray)); + + RequestOptions requestOptions = new RequestOptions() + .transforms(new CenterCrop(), new RoundedCorners(16)) + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.error_image); + + Glide.with(itemView.getContext()) + .load(finalImageUrl) + .apply(requestOptions) + .listener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, + Target target, + boolean isFirstResource) { + Log.e("FeedViewHolder", "Failed to load image: " + finalImageUrl + + ", error: " + (e != null ? e.getMessage() : "unknown")); + + if (finalImageUrl.startsWith("https://")) { + String httpUrl = "http://" + finalImageUrl.substring(8); + Log.d("FeedViewHolder", "Retrying with HTTP URL: " + httpUrl); + + Glide.with(itemView.getContext()) + .load(httpUrl) + .apply(requestOptions) + .into(postImage); + } + + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, + Object model, + Target target, + DataSource dataSource, + boolean isFirstResource) { + Log.d("FeedViewHolder", "Successfully loaded image: " + finalImageUrl); + // Clear the background when image loads + postImage.setBackgroundColor(0); + return false; // let Glide handle displaying the resource + } + }) + .into(postImage); + } catch (Exception e) { + Log.e("FeedViewHolder", "Error loading image: " + e.getMessage(), e); + postImage.setVisibility(View.VISIBLE); + Glide.with(itemView.getContext()) + .load(R.drawable.error_image) + .into(postImage); + } + } else { + Log.w("FeedViewHolder", "No image URL for post: " + post.getId()); + postImage.setVisibility(View.GONE); + } + + setupComments(post); + + if (post.isLikedByCurrentUser()) { + likeButton.setImageResource(R.drawable.ic_favorite); + likeButton.setColorFilter(itemView.getContext().getResources().getColor(R.color.system_red)); + } else { + likeButton.setImageResource(R.drawable.ic_favorite_border); + likeButton.setColorFilter(itemView.getContext().getResources().getColor(R.color.white)); + } + + likeButton.setOnClickListener(v -> { + if (itemView.getContext() instanceof FragmentActivity) { + FragmentActivity activity = (FragmentActivity) itemView.getContext(); + HomeFragment fragment = (HomeFragment) activity.getSupportFragmentManager() + .findFragmentById(R.id.fragment_container); + + if (fragment != null) { + fragment.handleLike(post); + } + } + }); + + commentButton.setOnClickListener(v -> { + if (itemView.getContext() instanceof FragmentActivity) { + FragmentActivity activity = (FragmentActivity) itemView.getContext(); + HomeFragment fragment = (HomeFragment) activity.getSupportFragmentManager() + .findFragmentById(R.id.fragment_container); + + if (fragment != null) { + fragment.showCommentDialog(post); + } + } + }); + + shareButton.setOnClickListener(v -> { + if (itemView.getContext() instanceof FragmentActivity) { + FragmentActivity activity = (FragmentActivity) itemView.getContext(); + HomeFragment fragment = (HomeFragment) activity.getSupportFragmentManager() + .findFragmentById(R.id.fragment_container); + + if (fragment != null) { + fragment.sharePost(post); + } + } + }); + } + + private void setupComments(FeedItem post) { + commentsContainer.removeAllViews(); + + List> comments = post.getComments(); + if (comments == null || comments.isEmpty()) { + commentsHeader.setVisibility(View.GONE); + commentsContainer.setVisibility(View.GONE); + viewAllComments.setVisibility(View.GONE); + return; + } + + commentsHeader.setVisibility(View.VISIBLE); + commentsContainer.setVisibility(View.VISIBLE); + + int commentCount = comments.size(); + int commentsToShow = Math.min(commentCount, 3); // Show up to 3 comments + + if (commentCount > 3) { + viewAllComments.setVisibility(View.VISIBLE); + viewAllComments.setText(String.format("View all %d comments", commentCount)); + + viewAllComments.setOnClickListener(v -> { + commentsContainer.removeAllViews(); + for (Map commentData : comments) { + addCommentView(commentData); + } + viewAllComments.setVisibility(View.GONE); + }); + } else { + viewAllComments.setVisibility(View.GONE); + } + + for (int i = 0; i < commentsToShow; i++) { + Map commentData = comments.get(i); + addCommentView(commentData); + } + } + + private void addCommentView(Map commentData) { + // Create a new comment view + View commentView = LayoutInflater.from(itemView.getContext()) + .inflate(R.layout.item_comment, commentsContainer, false); + + // Get views + TextView usernameView = commentView.findViewById(R.id.comment_username); + TextView textView = commentView.findViewById(R.id.comment_text); + + // Set data + String username = (String) commentData.get("username"); + String text = (String) commentData.get("text"); + + usernameView.setText(username != null ? username : "Anonymous"); + textView.setText(text != null ? text : ""); + + // Add to container + commentsContainer.addView(commentView); + } + } + } + + private void updateEmptyState() { + if (feedItems == null || feedItems.isEmpty()) { + emptyStateLayout.setVisibility(View.VISIBLE); + feedRecyclerView.setVisibility(View.GONE); + } else { + emptyStateLayout.setVisibility(View.GONE); + feedRecyclerView.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/com/pineapple/capture/fragment/OnDeleteClickListener.java b/app/src/main/java/com/pineapple/capture/fragment/OnDeleteClickListener.java new file mode 100644 index 0000000..3842aa9 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/fragment/OnDeleteClickListener.java @@ -0,0 +1,5 @@ +package com.pineapple.capture.fragment; + +public interface OnDeleteClickListener { + void onDeleteClick(String postId); +} diff --git a/app/src/main/java/com/pineapple/capture/fragment/ProfileFragment.java b/app/src/main/java/com/pineapple/capture/fragment/ProfileFragment.java new file mode 100644 index 0000000..984dce1 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/fragment/ProfileFragment.java @@ -0,0 +1,1038 @@ +package com.pineapple.capture.fragment; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.util.Log; +import android.content.pm.PackageManager; +import android.Manifest; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; + +import com.bumptech.glide.Glide; +import com.cloudinary.android.callback.ErrorInfo; +import com.cloudinary.android.callback.UploadCallback; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.firebase.auth.FirebaseAuth; +import com.pineapple.capture.R; +import com.pineapple.capture.activities.LoginActivity; +import com.pineapple.capture.profile.ProfileViewModel; +import com.pineapple.capture.models.User; +import com.pineapple.capture.activities.InterestsActivity; +import com.pineapple.capture.utils.CloudinaryManager; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import android.app.Activity; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Arrays; +import java.util.Locale; +import android.graphics.Bitmap; + +public class ProfileFragment extends Fragment { + + private ProfileViewModel viewModel; + private ImageView profileImage; + private TextView displayNameText; + private TextView usernameText; + private ImageButton editDisplayNameButton; + + // Bio elements + private Button addBioButton; + private LinearLayout bioContainer; + private TextView bioText; + private ImageButton editBioButton; + + // Location elements + private Button addLocationButton; + private LinearLayout locationContainer; + private TextView locationText; + private ImageButton editLocationButton; + + // Stats elements + private TextView friendsCountText; + private TextView followersCountText; + private TextView followingCountText; + private TextView postCountText; + + // Share profile button + private Button shareProfileButton; + + // Interests button + private Button addInterestsButton; + private LinearLayout interestsDisplayContainer; + private ChipGroup interestsChipGroup; + + private ActivityResultLauncher interestsLauncher; + private ActivityResultLauncher imagePickerLauncher; + private ActivityResultLauncher cameraLauncher; + private ActivityResultLauncher cameraPermissionLauncher; + private ActivityResultLauncher galleryPermissionLauncher; + + private Uri photoURI; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_profile, container, false); + + viewModel = new ViewModelProvider(this).get(ProfileViewModel.class); + + // Initialize views + profileImage = view.findViewById(R.id.profile_image); + profileImage.setOnClickListener(v -> showImageSourceOptions()); + displayNameText = view.findViewById(R.id.display_name); + usernameText = view.findViewById(R.id.username); + editDisplayNameButton = view.findViewById(R.id.edit_display_name_button); + ImageButton settingsButton = view.findViewById(R.id.settings_button); + + // Bio views + addBioButton = view.findViewById(R.id.add_bio_button); + bioContainer = view.findViewById(R.id.bio_container); + bioText = view.findViewById(R.id.bio_text); + editBioButton = view.findViewById(R.id.edit_bio_button); + + // Location views + addLocationButton = view.findViewById(R.id.add_location_button); + locationContainer = view.findViewById(R.id.location_container); + locationText = view.findViewById(R.id.location_text); + editLocationButton = view.findViewById(R.id.edit_location_button); + + // Stats views + friendsCountText = view.findViewById(R.id.friends_count); + followersCountText = view.findViewById(R.id.followers_count); + followingCountText = view.findViewById(R.id.following_count); + postCountText = view.findViewById(R.id.post_count); + + // Share profile button + shareProfileButton = view.findViewById(R.id.share_profile_button); + + // Interests views + addInterestsButton = view.findViewById(R.id.add_interests_button); + interestsDisplayContainer = view.findViewById(R.id.interests_display_container); + interestsChipGroup = view.findViewById(R.id.interests_chip_group); + + // Set up click listeners + editDisplayNameButton.setOnClickListener(v -> showEditDisplayNameDialog()); + settingsButton.setOnClickListener(v -> showAccountSettingsBottomSheet()); + + // Bio button listeners + addBioButton.setOnClickListener(v -> showEditBioDialog(null)); + editBioButton.setOnClickListener(v -> showEditBioDialog(bioText.getText().toString())); + + // Location button listeners + addLocationButton.setOnClickListener(v -> showEditLocationDialog(null)); + editLocationButton.setOnClickListener(v -> showEditLocationDialog(locationText.getText().toString())); + + // Share profile button listener + shareProfileButton.setOnClickListener(v -> shareProfile()); + + // Interests button listener + addInterestsButton.setOnClickListener(v -> openInterestsSelection()); + + // Register activity result launcher for interests + interestsLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + viewModel.loadUserData(); + } + }); + + imagePickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri selectedImageUri = result.getData().getData(); + if (selectedImageUri != null) { + uploadProfileImage(selectedImageUri); + } + } + }); + + cameraLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + if (photoURI != null) { + uploadProfileImage(photoURI); + } else if (result.getData() != null && result.getData().getExtras() != null) { + Bundle extras = result.getData().getExtras(); + Bitmap imageBitmap = (Bitmap) extras.get("data"); + + if (imageBitmap != null) { + Uri imageUri = saveBitmapToFile(imageBitmap); + if (imageUri != null) { + uploadProfileImage(imageUri); + } + } else { + Toast.makeText(requireContext(), "Failed to capture image", Toast.LENGTH_SHORT).show(); + } + } + } + }); + + // Register camera permission launcher + cameraPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + isGranted -> { + if (isGranted) { + openCamera(); + } else { + Toast.makeText(requireContext(), "Camera permission denied", Toast.LENGTH_SHORT).show(); + } + }); + + // Register gallery permission launcher + galleryPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + isGranted -> { + if (isGranted) { + openGallery(); + } else { + Toast.makeText(requireContext(), "Storage permission denied", Toast.LENGTH_SHORT).show(); + } + }); + + viewModel.getUserData().observe(getViewLifecycleOwner(), user -> { + if (user != null) { + updateUI(user); + } + }); + + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + } + }); + + return view; + } + + private void updateUI(User user) { + // update basic info + displayNameText.setText(user.getDisplayName()); + usernameText.setText("@" + user.getUsername()); + + // load profile image using Glide + String profileUrl = user.getPrimaryProfilePictureUrl(); + if (profileUrl != null && !profileUrl.isEmpty()) { + Glide.with(this) + .load(profileUrl) + .circleCrop() + .into(profileImage); + } + + // update bio + if (user.getBio() != null && !user.getBio().isEmpty()) { + bioText.setText(user.getBio()); + bioContainer.setVisibility(View.VISIBLE); + addBioButton.setVisibility(View.GONE); + } else { + bioContainer.setVisibility(View.GONE); + addBioButton.setVisibility(View.VISIBLE); + } + + // update location + if (user.getLocation() != null && !user.getLocation().isEmpty()) { + locationText.setText(user.getLocation()); + locationContainer.setVisibility(View.VISIBLE); + addLocationButton.setVisibility(View.GONE); + } else { + locationContainer.setVisibility(View.GONE); + addLocationButton.setVisibility(View.VISIBLE); + } + + // update interests + List interests = user.getInterests(); + if (interests != null && !interests.isEmpty()) { + addInterestsButton.setText("Edit Interests (" + interests.size() + ")"); + displayInterests(interests); + } else { + addInterestsButton.setText("+ Add Interests"); + interestsDisplayContainer.setVisibility(View.GONE); + } + + // update stats + friendsCountText.setText(String.valueOf(user.getFriendsCount())); + followersCountText.setText(String.valueOf(user.getFollowersCount())); + followingCountText.setText(String.valueOf(user.getFollowingCount())); + postCountText.setText(String.valueOf(user.getPostCount())); + } + + private void displayInterests(List interests) { + interestsChipGroup.removeAllViews(); + interestsDisplayContainer.setVisibility(View.VISIBLE); + + // configure chip group + interestsChipGroup.setChipSpacingHorizontal(16); + interestsChipGroup.setChipSpacingVertical(8); + + for (int i = 0; i < interests.size(); i++) { + String interest = interests.get(i); + Chip chip = new Chip(requireContext()); + chip.setText(interest); + + // set up chip styling with a pastel color based on interest category + int chipColor = getInterestCategoryColor(interest); + + chip.setChipBackgroundColorResource(chipColor); + chip.setTextColor(getResources().getColor(R.color.black)); // Dark text for better contrast with pastel colors + chip.setClickable(false); // No need for chips to be clickable in profile view + chip.setElevation(2f); // Add slight elevation + chip.setChipCornerRadius(16f); // More rounded corners + + int iconResId = getInterestIconResource(interest); + if (iconResId != 0) { + chip.setChipIconResource(iconResId); + chip.setChipIconVisible(true); + chip.setChipIconSize(24f); // Ensure icon is large enough to be visible + chip.setIconEndPadding(4f); // Add padding after icon + chip.setIconStartPadding(4f); // Add padding before icon + chip.setChipIconTint(null); // Ensure icon is not being tinted/hidden + } + + // log whether an icon was found for debugging + if (iconResId == 0) { + Log.d("ProfileFragment", "No icon found for interest: " + interest); + } else { + Log.d("ProfileFragment", "Icon set for interest: " + interest + " with ID: " + iconResId); + } + + interestsChipGroup.addView(chip); + } + } + + /** + * Assigns consistent colors to interests based on their category + */ + private int getInterestCategoryColor(String interest) { + String lowerInterest = interest.toLowerCase(); + + // creativity category (orange/yellow) + if (Arrays.asList("art", "design", "photography", "crafts", "fashion", "singing", "dancing", "video", "cosplay", "make-up").contains(lowerInterest)) { + if (lowerInterest.equals("art")) return R.color.pastel_orange; + if (lowerInterest.equals("dancing")) return R.color.pastel_yellow; + if (lowerInterest.equals("photography")) return R.color.pastel_orange; + if (lowerInterest.equals("singing")) return R.color.pastel_yellow; + return R.color.pastel_orange; + } + + // sports category (blue/green) + if (Arrays.asList("badminton", "bouldering", "crew", "baseball", "bowling", "cricket", "basketball", "boxing", "cycling").contains(lowerInterest)) { + if (lowerInterest.equals("basketball")) return R.color.pastel_blue; + if (lowerInterest.equals("cycling")) return R.color.pastel_green; + if (lowerInterest.equals("baseball")) return R.color.pastel_blue; + return R.color.pastel_green; + } + + // pets category (purple/pink) + if (Arrays.asList("amphibians", "cats", "horses", "arthropods", "dogs", "rabbits", "birds", "fish", "reptiles", "turtles").contains(lowerInterest)) { + if (lowerInterest.equals("cats")) return R.color.pastel_purple; + if (lowerInterest.equals("dogs")) return R.color.pastel_pink; + if (lowerInterest.equals("birds")) return R.color.pastel_purple; + return R.color.pastel_pink; + } + + // default colors based on first letter for any other interest + char firstChar = lowerInterest.charAt(0); + switch (firstChar % 8) { + case 0: return R.color.pastel_blue; + case 1: return R.color.pastel_green; + case 2: return R.color.pastel_purple; + case 3: return R.color.pastel_pink; + case 4: return R.color.pastel_orange; + case 5: return R.color.pastel_yellow; + case 6: return R.color.pastel_teal; + case 7: return R.color.pastel_cyan; + default: return R.color.pastel_blue; + } + } + + private int getInterestIconResource(String interest) { + String lowerInterest = interest.toLowerCase(); + + // direct mappings for specific interests + if (lowerInterest.equals("art")) return R.drawable.ic_art; + if (lowerInterest.equals("dancing")) return R.drawable.ic_dancing; + if (lowerInterest.equals("photography")) return R.drawable.ic_photography; + if (lowerInterest.equals("singing") || lowerInterest.equals("music")) return R.drawable.ic_music; + + // category-based mappings + if (lowerInterest.equals("video") || lowerInterest.equals("design") || + lowerInterest.equals("crafts") || lowerInterest.equals("fashion") || + lowerInterest.equals("cosplay") || lowerInterest.equals("make-up")) { + return R.drawable.ic_creativity; + } + + if (lowerInterest.equals("badminton") || lowerInterest.equals("bouldering") || + lowerInterest.equals("crew") || lowerInterest.equals("baseball") || + lowerInterest.equals("bowling") || lowerInterest.equals("cricket") || + lowerInterest.equals("basketball") || lowerInterest.equals("boxing") || + lowerInterest.equals("cycling")) { + return R.drawable.ic_sports; + } + + if (lowerInterest.equals("amphibians") || lowerInterest.equals("cats") || + lowerInterest.equals("horses") || lowerInterest.equals("arthropods") || + lowerInterest.equals("dogs") || lowerInterest.equals("rabbits") || + lowerInterest.equals("birds") || lowerInterest.equals("fish") || + lowerInterest.equals("reptiles") || lowerInterest.equals("turtles")) { + return R.drawable.ic_pets; + } + + // default icon based on first letter + char firstChar = lowerInterest.charAt(0); + switch (firstChar % 3) { + case 0: return R.drawable.ic_creativity; + case 1: return R.drawable.ic_sports; + case 2: return R.drawable.ic_pets; + default: return R.drawable.ic_favorite; + } + } + + private void showEditDisplayNameDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_edit_display_name, null); + EditText newDisplayNameInput = dialogView.findViewById(R.id.new_display_name_input); + + MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Edit Display Name") + .setView(dialogView) + .setPositiveButton("Update", null) + .setNegativeButton("Cancel", null); + + AlertDialog dialog = dialogBuilder.create(); + dialog.setOnShowListener(dialogInterface -> { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + String newDisplayName = newDisplayNameInput.getText().toString().trim(); + + if (newDisplayName.isEmpty()) { + Toast.makeText(requireContext(), "Display name cannot be empty", Toast.LENGTH_SHORT).show(); + return; + } + if (newDisplayName.length() > 30) { + Toast.makeText(requireContext(), "Display name cannot be longer than 30 characters", Toast.LENGTH_SHORT).show(); + return; + } + // formatting display name + if (!newDisplayName.matches("^[a-zA-Z0-9 .,'!?-]+$")) { + Toast.makeText(requireContext(), "Display name can only contain letters, numbers, spaces, and basic punctuation", Toast.LENGTH_SHORT).show(); + return; + } + + positiveButton.setEnabled(false); + newDisplayNameInput.setEnabled(false); + + viewModel.updateDisplayNameOnly(newDisplayName); + + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + if (message.contains("successfully")) { + dialog.dismiss(); + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + positiveButton.setEnabled(true); + newDisplayNameInput.setEnabled(true); + } + }); + }); + }); + dialog.show(); + } + + private void showEditBioDialog(String currentBio) { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_edit_bio, null); + EditText bioInput = dialogView.findViewById(R.id.bio_input); + TextView charCountText = dialogView.findViewById(R.id.bio_char_count); + + if (currentBio != null) { + bioInput.setText(currentBio); + charCountText.setText(currentBio.length() + "/150"); + } + + bioInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + charCountText.setText(s.length() + "/150"); + } + }); + + MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Edit Bio") + .setView(dialogView) + .setPositiveButton("Save", null) + .setNegativeButton("Cancel", null); + + AlertDialog dialog = dialogBuilder.create(); + dialog.setOnShowListener(dialogInterface -> { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + String newBio = bioInput.getText().toString().trim(); + + positiveButton.setEnabled(false); + bioInput.setEnabled(false); + + viewModel.updateBio(newBio); + + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + if (message.contains("successfully")) { + dialog.dismiss(); + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + positiveButton.setEnabled(true); + bioInput.setEnabled(true); + } + }); + }); + }); + dialog.show(); + } + + private void showEditLocationDialog(String currentLocation) { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_edit_location, null); + EditText locationInput = dialogView.findViewById(R.id.location_input); + TextView charCountText = dialogView.findViewById(R.id.location_char_count); + + if (currentLocation != null) { + locationInput.setText(currentLocation); + charCountText.setText(currentLocation.length() + "/50"); + } + + locationInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + charCountText.setText(s.length() + "/50"); + } + }); + + MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Edit Location") + .setView(dialogView) + .setPositiveButton("Save", null) + .setNegativeButton("Cancel", null); + + AlertDialog dialog = dialogBuilder.create(); + dialog.setOnShowListener(dialogInterface -> { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + String newLocation = locationInput.getText().toString().trim(); + + positiveButton.setEnabled(false); + locationInput.setEnabled(false); + + viewModel.updateLocation(newLocation); + + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + if (message.contains("successfully")) { + dialog.dismiss(); + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + positiveButton.setEnabled(true); + locationInput.setEnabled(true); + } + }); + }); + }); + dialog.show(); + } + + private void shareProfile() { + User user = viewModel.getUserData().getValue(); + if (user == null) return; + + String shareText = "Check out my profile on Capture App!\n\n" + + "Name: " + user.getDisplayName() + "\n" + + "Username: @" + user.getUsername(); + + if (user.getBio() != null && !user.getBio().isEmpty()) { + shareText += "\n\nBio: " + user.getBio(); + } + + if (user.getLocation() != null && !user.getLocation().isEmpty()) { + shareText += "\n\nLocation: " + user.getLocation(); + } + + shareText += "\n\nFollowers: " + user.getFollowersCount() + + " | Following: " + user.getFollowingCount(); + + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, shareText); + + startActivity(Intent.createChooser(shareIntent, "Share via")); + } + + private void showEditEmailDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_edit_email, null); + EditText currentPasswordInput = dialogView.findViewById(R.id.current_password_input); + EditText newEmailInput = dialogView.findViewById(R.id.new_email_input); + + MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Change Email") + .setView(dialogView) + .setPositiveButton("Update", null) // Set to null to handle click manually + .setNegativeButton("Cancel", null); + + AlertDialog dialog = dialogBuilder.create(); + dialog.setOnShowListener(dialogInterface -> { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + String currentPassword = currentPasswordInput.getText().toString().trim(); + String newEmail = newEmailInput.getText().toString().trim(); + + if (currentPassword.isEmpty()) { + Toast.makeText(requireContext(), "Please enter your current password", + Toast.LENGTH_SHORT).show(); + return; + } + + if (newEmail.isEmpty()) { + Toast.makeText(requireContext(), "Please enter a new email", + Toast.LENGTH_SHORT).show(); + return; + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(newEmail).matches()) { + Toast.makeText(requireContext(), "Please enter a valid email address", + Toast.LENGTH_SHORT).show(); + return; + } + + // Show loading state + positiveButton.setEnabled(false); + currentPasswordInput.setEnabled(false); + newEmailInput.setEnabled(false); + + viewModel.updateEmail(currentPassword, newEmail); + + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + if (message.contains("successfully")) { + dialog.dismiss(); + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + + positiveButton.setEnabled(true); + currentPasswordInput.setEnabled(true); + newEmailInput.setEnabled(true); + } + }); + }); + }); + + dialog.show(); + } + + private void showChangePasswordDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_change_password, null); + EditText currentPasswordInput = dialogView.findViewById(R.id.current_password_input); + EditText newPasswordInput = dialogView.findViewById(R.id.new_password_input); + EditText confirmPasswordInput = dialogView.findViewById(R.id.confirm_password_input); + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Change Password") + .setView(dialogView) + .setPositiveButton("Update", (dialog, which) -> { + String currentPassword = currentPasswordInput.getText().toString(); + String newPassword = newPasswordInput.getText().toString(); + String confirmPassword = confirmPasswordInput.getText().toString(); + + if (newPassword.equals(confirmPassword)) { + viewModel.updatePassword(currentPassword, newPassword); + } else { + Toast.makeText(requireContext(), + "New passwords do not match", Toast.LENGTH_SHORT).show(); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private void showDeleteAccountDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_delete_account, null); + EditText passwordInput = dialogView.findViewById(R.id.password_input); + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete Account") + .setMessage("Are you sure you want to delete your account? This action cannot be undone.") + .setView(dialogView) + .setPositiveButton("Delete", (dialog, which) -> { + String password = passwordInput.getText().toString(); + viewModel.deleteAccount(password); + viewModel.getDeletionResult().observe(getViewLifecycleOwner(), result -> { + if (result != null) { + if (result) { + Intent intent = new Intent(requireContext(), LoginActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + requireActivity().finish(); + } else { + Toast.makeText(requireContext(), + "Failed to delete account", + Toast.LENGTH_SHORT).show(); + } + } + }); + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private void logout() { + FirebaseAuth.getInstance().signOut(); + Intent intent = new Intent(requireContext(), LoginActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + requireActivity().finish(); + } + + private void showEditUsernameDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_edit_username, null); + EditText newUsernameInput = dialogView.findViewById(R.id.new_username_input); + + MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Change Username") + .setView(dialogView) + .setPositiveButton("Update", null) + .setNegativeButton("Cancel", null); + + AlertDialog dialog = dialogBuilder.create(); + dialog.setOnShowListener(dialogInterface -> { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + String newUsername = newUsernameInput.getText().toString().trim(); + + if (newUsername.isEmpty()) { + Toast.makeText(requireContext(), "Please enter a username", + Toast.LENGTH_SHORT).show(); + return; + } + + if (newUsername.length() > 30) { + Toast.makeText(requireContext(), "Username cannot be longer than 30 characters", + Toast.LENGTH_SHORT).show(); + return; + } + + if (newUsername.contains(" ")) { + Toast.makeText(requireContext(), "Username cannot contain spaces", + Toast.LENGTH_SHORT).show(); + return; + } + + if (!newUsername.matches("^[a-zA-Z0-9._]+$")) { + Toast.makeText(requireContext(), + "Username can only contain letters, numbers, periods, and underscores", + Toast.LENGTH_SHORT).show(); + return; + } + + if (newUsername.startsWith(".") || newUsername.endsWith(".")) { + Toast.makeText(requireContext(), + "Username cannot start or end with a period", + Toast.LENGTH_SHORT).show(); + return; + } + + positiveButton.setEnabled(false); + newUsernameInput.setEnabled(false); + + viewModel.updateDisplayName(newUsername); + viewModel.getErrorMessage().observe(getViewLifecycleOwner(), message -> { + if (message != null && !message.isEmpty()) { + if (message.contains("successfully")) { + dialog.dismiss(); + } + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show(); + + // Re-enable inputs + positiveButton.setEnabled(true); + newUsernameInput.setEnabled(true); + } + }); + }); + }); + + dialog.show(); + } + + private boolean isValidDisplayName(String displayName) { + return displayName.matches("^[a-zA-Z0-9\\s.,!?-]+$"); + } + + private void showAccountSettingsBottomSheet() { + BottomSheetDialog bottomSheetDialog = new BottomSheetDialog(requireContext()); + View sheetView = getLayoutInflater().inflate(R.layout.bottom_sheet_account_settings, null); + bottomSheetDialog.setContentView(sheetView); + + sheetView.findViewById(R.id.action_change_username).setOnClickListener(v -> { + bottomSheetDialog.dismiss(); + showEditUsernameDialog(); + }); + sheetView.findViewById(R.id.action_change_display_name).setOnClickListener(v -> { + bottomSheetDialog.dismiss(); + showEditDisplayNameDialog(); + }); + sheetView.findViewById(R.id.action_change_password).setOnClickListener(v -> { + bottomSheetDialog.dismiss(); + showChangePasswordDialog(); + }); + sheetView.findViewById(R.id.action_logout).setOnClickListener(v -> { + bottomSheetDialog.dismiss(); + logout(); + }); + sheetView.findViewById(R.id.action_delete_account).setOnClickListener(v -> { + bottomSheetDialog.dismiss(); + showDeleteAccountDialog(); + }); + + bottomSheetDialog.show(); + } + + private void openInterestsSelection() { + Intent intent = new Intent(requireContext(), InterestsActivity.class); + interestsLauncher.launch(intent); + } + + /** + * Shows a dialog with options to take a photo or choose from gallery + */ + private void showImageSourceOptions() { + final CharSequence[] options = {"Take Photo", "Choose from Gallery", "Cancel"}; + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle("Choose Profile Picture"); + + builder.setItems(options, (dialog, item) -> { + if (options[item].equals("Take Photo")) { + checkCameraPermission(); + } else if (options[item].equals("Choose from Gallery")) { + checkStoragePermission(); + } else if (options[item].equals("Cancel")) { + dialog.dismiss(); + } + }); + + builder.show(); + } + + /** + * Check if camera permission is granted, request if not + */ + private void checkCameraPermission() { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA); + } else { + openCamera(); + } + } + + /** + * Check if storage permission is granted, request if not + */ + private void checkStoragePermission() { + // For Android 13+ (API level 33 and higher) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.READ_MEDIA_IMAGES) + != PackageManager.PERMISSION_GRANTED) { + galleryPermissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES); + } else { + openGallery(); + } + } else { + // For Android 12 and below + if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + galleryPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE); + } else { + openGallery(); + } + } + } + + /** + * Opens the camera to take a profile picture + */ + private void openCamera() { + Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + + File photoFile = null; + try { + photoFile = createImageFile(); + } catch (IOException ex) { + Toast.makeText(requireContext(), "Error creating image file", Toast.LENGTH_SHORT).show(); + return; + } + + if (photoFile != null) { + try { + photoURI = FileProvider.getUriForFile(requireContext(), + "com.pineapple.capture.fileprovider", + photoFile); + + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); + try { + cameraLauncher.launch(cameraIntent); + } catch (Exception e) { + Log.e("ProfileFragment", "Error launching camera: " + e.getMessage()); + Toast.makeText(requireContext(), "Error launching camera: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + Log.d("ProfileFragment", "Camera intent couldn't be resolved, trying basic intent"); + Intent basicCameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + photoURI = null; + + cameraLauncher.launch(basicCameraIntent); + } + } catch (Exception e) { + Log.e("ProfileFragment", "Error launching camera: " + e.getMessage()); + Toast.makeText(requireContext(), "Error launching camera: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + + /** + * Save bitmap to file and return its URI + */ + private Uri saveBitmapToFile(Bitmap bitmap) { + File imagesDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES); + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date()); + File imageFile = new File(imagesDir, "JPEG_" + timeStamp + ".jpg"); + + try { + java.io.FileOutputStream fos = new java.io.FileOutputStream(imageFile); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos); + fos.flush(); + fos.close(); + + return FileProvider.getUriForFile(requireContext(), + "com.pineapple.capture.fileprovider", + imageFile); + } catch (Exception e) { + Log.e("ProfileFragment", "Error saving bitmap: " + e.getMessage()); + Toast.makeText(requireContext(), "Error saving image", Toast.LENGTH_SHORT).show(); + return null; + } + } + + /** + * Creates a temporary file for storing camera photos + */ + private File createImageFile() throws IOException { + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date()); + String imageFileName = "JPEG_" + timeStamp + "_"; + File storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES); + + return File.createTempFile( + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ + ); + } + + /** + * Opens the gallery to select a profile picture + */ + private void openGallery() { + Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + imagePickerLauncher.launch(intent); + } + + /** + * Uploads the selected image to Cloudinary and updates the user's profile + */ + private void uploadProfileImage(Uri imageUri) { + Toast.makeText(requireContext(), "Uploading profile picture...", Toast.LENGTH_SHORT).show(); + + CloudinaryManager.init(requireContext()); + + CloudinaryManager.uploadImage(imageUri, new UploadCallback() { + @Override + public void onStart(String requestId) { + Log.d("ProfileFragment", "Started uploading profile picture"); + } + + @Override + public void onProgress(String requestId, long bytes, long totalBytes) { + double progress = (double) bytes / totalBytes; + Log.d("ProfileFragment", "Upload progress: " + (int)(progress * 100) + "%"); + } + + @Override + public void onSuccess(String requestId, Map resultData) { + String imageUrl = CloudinaryManager.getImageUrl(resultData); + Log.d("ProfileFragment", "Cloudinary upload successful, image URL: " + imageUrl); + + if (imageUrl != null && !imageUrl.isEmpty()) { + viewModel.updateProfilePicture(imageUrl); + + requireActivity().runOnUiThread(() -> { + Glide.with(ProfileFragment.this) + .load(imageUrl) + .circleCrop() + .into(profileImage); + }); + } + } + + @Override + public void onError(String requestId, ErrorInfo error) { + Log.e("ProfileFragment", "Error uploading to Cloudinary: " + error.getDescription()); + requireActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), + "Failed to upload image: " + error.getDescription(), + Toast.LENGTH_LONG).show(); + }); + } + + @Override + public void onReschedule(String requestId, ErrorInfo error) { + Log.e("ProfileFragment", "Cloudinary upload rescheduled due to error: " + error.getDescription()); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/friends/Friend.java b/app/src/main/java/com/pineapple/capture/friends/Friend.java new file mode 100644 index 0000000..4eb2198 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/friends/Friend.java @@ -0,0 +1,30 @@ +package com.pineapple.capture.friends; + +public class Friend { + private String userId; + private String name; + private String profileImageUrl; + private long friendsSince; + + //empty constructor for Firestore + public Friend() {} + + public Friend(String userId, String name, String profileImageUrl) { + this.userId = userId; + this.name = name; + this.profileImageUrl = profileImageUrl; + this.friendsSince = System.currentTimeMillis(); + } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getProfileImageUrl() { return profileImageUrl; } + public void setProfileImageUrl(String profileImageUrl) { this.profileImageUrl = profileImageUrl; } + + public long getFriendsSince() { return friendsSince; } + public void setFriendsSince(long friendsSince) { this.friendsSince = friendsSince; } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/friends/FriendsActivity.java b/app/src/main/java/com/pineapple/capture/friends/FriendsActivity.java new file mode 100644 index 0000000..57ca801 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/friends/FriendsActivity.java @@ -0,0 +1,28 @@ +package com.pineapple.capture.friends; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.pineapple.capture.R; + +public class FriendsActivity extends AppCompatActivity { + private FriendsViewModel viewModel; + private RecyclerView recyclerView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_friends); + + viewModel = new ViewModelProvider(this).get(FriendsViewModel.class); + + recyclerView = findViewById(R.id.friends_recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + + viewModel.getFriends().observe(this, friends -> { + // Update RecyclerView adapter with new friends list + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/friends/FriendsViewModel.java b/app/src/main/java/com/pineapple/capture/friends/FriendsViewModel.java new file mode 100644 index 0000000..3b638a2 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/friends/FriendsViewModel.java @@ -0,0 +1,68 @@ +package com.pineapple.capture.friends; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.firestore.FirebaseFirestore; +import java.util.List; +import java.util.ArrayList; + +public class FriendsViewModel extends ViewModel { + private FirebaseFirestore db; + private FirebaseAuth auth; + private MutableLiveData> friends; + + public FriendsViewModel() { + db = FirebaseFirestore.getInstance(); + auth = FirebaseAuth.getInstance(); + friends = new MutableLiveData<>(new ArrayList<>()); + loadFriends(); + } + + private void loadFriends() { + String userId = auth.getCurrentUser() != null ? auth.getCurrentUser().getUid() : null; + if (userId != null) { + db.collection("users").document(userId) + .collection("friends") + .addSnapshotListener((value, error) -> { + if (error != null) { + return; + } + List friendsList = new ArrayList<>(); + if (value != null) { + for (com.google.firebase.firestore.DocumentSnapshot doc : value) { + Friend friend = doc.toObject(Friend.class); + if (friend != null) { + friendsList.add(friend); + } + } + } + friends.setValue(friendsList); + }); + } + } + + public void addFriend(String friendId) { + String userId = auth.getCurrentUser() != null ? auth.getCurrentUser().getUid() : null; + if (userId != null) { + db.collection("users").document(friendId) + .get() + .addOnSuccessListener(documentSnapshot -> { + if (documentSnapshot.exists()) { + Friend friend = documentSnapshot.toObject(Friend.class); + if (friend != null) { + db.collection("users").document(userId) + .collection("friends") + .document(friendId) + .set(friend); + } + } + }); + } + } + + public LiveData> getFriends() { + return friends; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/models/User.java b/app/src/main/java/com/pineapple/capture/models/User.java new file mode 100644 index 0000000..8feeb99 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/models/User.java @@ -0,0 +1,175 @@ +package com.pineapple.capture.models; + +import com.google.firebase.firestore.Exclude; + +import java.util.ArrayList; +import java.util.List; + +public class User { + private String id; + private String username; + private String email; + private List profilePictureUrl; + private String displayName; + private String bio; + private String location; + private List followers = new ArrayList<>(); + private List following = new ArrayList<>(); + private List interests = new ArrayList<>(); + private int postCount = 0; // Number of posts the user has created + + public User() { + // Required empty constructor for Firestore + } + + public User(String id, String username, String email, List profilePictureUrl, + String displayName, String bio, String location) { + this.id = id; + this.username = username; + this.email = email; + this.profilePictureUrl = profilePictureUrl; + this.displayName = displayName; + this.bio = bio; + this.location = location; + this.followers = new ArrayList<>(); + this.following = new ArrayList<>(); + this.interests = new ArrayList<>(); + this.postCount = 0; + } + + @Exclude + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public List getProfilePictureUrl() { + return profilePictureUrl; + } + + public void setProfilePictureUrl(List profilePictureUrl) { + this.profilePictureUrl = profilePictureUrl; + } + + /** + * Convenience method to get the primary profile picture URL + * @return The primary profile picture URL or empty string if none exists + */ + @Exclude + public String getPrimaryProfilePictureUrl() { + if (profilePictureUrl == null || profilePictureUrl.isEmpty()) { + return ""; + } + return profilePictureUrl.get(0); + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public List getFollowers() { + if (followers == null) { + followers = new ArrayList<>(); + } + return followers; + } + + public void setFollowers(List followers) { + this.followers = followers; + } + + public List getFollowing() { + if (following == null) { + following = new ArrayList<>(); + } + return following; + } + + public void setFollowing(List following) { + this.following = following; + } + + @Exclude + public int getFollowersCount() { + return followers != null ? followers.size() : 0; + } + + @Exclude + public int getFollowingCount() { + return following != null ? following.size() : 0; + } + + @Exclude + public int getFriendsCount() { + // Friends are mutual followers (people who follow you and you follow them) + if (followers == null || following == null) { + return 0; + } + + int count = 0; + for (String followerId : followers) { + if (following.contains(followerId)) { + count++; + } + } + return count; + } + + public int getPostCount() { + return postCount; + } + + public void setPostCount(int postCount) { + this.postCount = postCount; + } + + public List getInterests() { + if (interests == null) { + interests = new ArrayList<>(); + } + return interests; + } + + public void setInterests(List interests) { + this.interests = interests; + } +} diff --git a/app/src/main/java/com/pineapple/capture/profile/ProfileActivity.java b/app/src/main/java/com/pineapple/capture/profile/ProfileActivity.java new file mode 100644 index 0000000..aaeb00c --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/profile/ProfileActivity.java @@ -0,0 +1,46 @@ +package com.pineapple.capture.profile; + +import android.os.Bundle; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.ViewModelProvider; +import com.bumptech.glide.Glide; +import com.pineapple.capture.R; + +public class ProfileActivity extends AppCompatActivity { + private ProfileViewModel viewModel; + private ImageView profileImage; + private TextView displayName; + private TextView userName; + private TextView userEmail; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_profile); + + viewModel = new ViewModelProvider(this).get(ProfileViewModel.class); + + profileImage = findViewById(R.id.profile_image); + displayName = findViewById(R.id.user_name); + userName = findViewById(R.id.username); + userEmail = findViewById(R.id.user_email); + + // Observe user data changes + viewModel.getUserData().observe(this, user -> { + if (user != null) { + displayName.setText(user.getDisplayName()); + userName.setText(user.getUsername()); + userEmail.setText(user.getEmail()); + String profileUrl = user.getPrimaryProfilePictureUrl(); + if (profileUrl != null && !profileUrl.isEmpty()) { + Glide.with(this) + .load(profileUrl) + .circleCrop() + .into(profileImage); + } + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/profile/ProfileViewModel.java b/app/src/main/java/com/pineapple/capture/profile/ProfileViewModel.java new file mode 100644 index 0000000..0ededc9 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/profile/ProfileViewModel.java @@ -0,0 +1,587 @@ +package com.pineapple.capture.profile; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseUser; +import com.google.firebase.auth.AuthCredential; +import com.google.firebase.auth.EmailAuthProvider; +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException; +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException; +import com.google.firebase.firestore.FirebaseFirestore; +import com.pineapple.capture.models.User; + +import java.util.ArrayList; +import java.util.List; + +public class ProfileViewModel extends ViewModel { + private final FirebaseAuth mAuth; + private final FirebaseFirestore db; + private final MutableLiveData userData; + private final MutableLiveData errorMessage; + private final MutableLiveData isLoading; + private final MutableLiveData deletionResult; + + public ProfileViewModel() { + mAuth = FirebaseAuth.getInstance(); + db = FirebaseFirestore.getInstance(); + userData = new MutableLiveData<>(); + errorMessage = new MutableLiveData<>(); + isLoading = new MutableLiveData<>(false); + deletionResult = new MutableLiveData<>(); + loadUserData(); + } + + public void loadUserData() { + FirebaseUser currentUser = mAuth.getCurrentUser(); + if (currentUser != null) { + isLoading.setValue(true); + db.collection("users").document(currentUser.getUid()) + .get() + .addOnSuccessListener(documentSnapshot -> { + if (!documentSnapshot.exists()) { + errorMessage.setValue("User document does not exist"); + isLoading.setValue(false); + return; + } + + User user = documentSnapshot.toObject(User.class); + if (user != null) { + user.setId(currentUser.getUid()); + // Set display name from Firebase User if not set in Firestore + if (user.getDisplayName() == null || user.getDisplayName().isEmpty()) { + user.setDisplayName(currentUser.getDisplayName()); + } + + // Fetch post count from Firestore + fetchPostCount(user); + } else { + errorMessage.setValue("Failed to convert document to User object"); + isLoading.setValue(false); + } + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to load user data: " + e.getMessage()); + isLoading.setValue(false); + }); + } else { + errorMessage.setValue("No current user found"); + } + } + + private void fetchPostCount(User user) { + // Query posts collection for documents with matching userId + db.collection("posts") + .whereEqualTo("userId", user.getId()) + .get() + .addOnSuccessListener(querySnapshot -> { + int count = querySnapshot.size(); + user.setPostCount(count); + + // Update UI with the complete user data including post count + userData.setValue(user); + isLoading.setValue(false); + }) + .addOnFailureListener(e -> { + // Still update UI with user data, just without accurate post count + userData.setValue(user); + errorMessage.setValue("Failed to load post count: " + e.getMessage()); + isLoading.setValue(false); + }); + } + + public void updateEmail(String currentPassword, String newEmail) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // First, re-authenticate the user + AuthCredential credential = EmailAuthProvider.getCredential(user.getEmail(), currentPassword); + user.reauthenticate(credential) + .addOnCompleteListener(task -> { + if (task.isSuccessful()) { + // After successful re-authentication, update the email + user.updateEmail(newEmail) + .addOnCompleteListener(emailTask -> { + if (emailTask.isSuccessful()) { + // Update email in Firestore + db.collection("users").document(user.getUid()) + .update("email", newEmail) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setEmail(newEmail); + userData.setValue(currentUser); + } + errorMessage.setValue("Email updated successfully"); + }) + .addOnFailureListener(e -> { + // If Firestore update fails, revert the email in Firebase Auth + user.updateEmail(user.getEmail()) + .addOnCompleteListener(revertTask -> { + if (!revertTask.isSuccessful()) { + errorMessage.setValue("Failed to update email and couldn't revert changes. Please contact support."); + } else { + errorMessage.setValue("Failed to update email in database. Changes reverted."); + } + }); + }); + } else { + Exception exception = emailTask.getException(); + if (exception instanceof FirebaseAuthRecentLoginRequiredException) { + errorMessage.setValue("Please sign in again to change your email"); + } else if (exception instanceof FirebaseAuthInvalidCredentialsException) { + errorMessage.setValue("Invalid email format"); + } else { + errorMessage.setValue("Failed to update email: " + exception.getMessage()); + } + } + }); + } else { + Exception exception = task.getException(); + if (exception instanceof FirebaseAuthInvalidCredentialsException) { + errorMessage.setValue("Current password is incorrect"); + } else if (exception instanceof FirebaseAuthRecentLoginRequiredException) { + errorMessage.setValue("Please sign in again to change your email"); + } else { + errorMessage.setValue("Authentication failed: " + exception.getMessage()); + } + } + }); + } + + public void updatePassword(String currentPassword, String newPassword) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user != null) { + isLoading.setValue(true); + // Re-authenticate user before changing password + com.google.firebase.auth.AuthCredential credential = + com.google.firebase.auth.EmailAuthProvider.getCredential( + user.getEmail(), currentPassword); + + user.reauthenticate(credential) + .addOnCompleteListener(task -> { + if (task.isSuccessful()) { + user.updatePassword(newPassword) + .addOnCompleteListener(passwordTask -> { + if (passwordTask.isSuccessful()) { + errorMessage.setValue("Password updated successfully"); + } else { + errorMessage.setValue("Failed to update password: " + + passwordTask.getException().getMessage()); + } + isLoading.setValue(false); + }); + } else { + errorMessage.setValue("Current password is incorrect"); + isLoading.setValue(false); + } + }); + } + } + + public void deleteAccount(String currentPassword) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user != null) { + isLoading.setValue(true); + // Re-authenticate user before deleting account + com.google.firebase.auth.AuthCredential credential = + com.google.firebase.auth.EmailAuthProvider.getCredential( + user.getEmail(), currentPassword); + + user.reauthenticate(credential) + .addOnCompleteListener(task -> { + if (task.isSuccessful()) { + // Delete user data from Firestore + db.collection("users").document(user.getUid()) + .delete() + .addOnSuccessListener(aVoid -> { + // Delete user account + user.delete() + .addOnCompleteListener(deleteTask -> { + if (deleteTask.isSuccessful()) { + deletionResult.setValue(true); + errorMessage.setValue("Account deleted successfully"); + } else { + deletionResult.setValue(false); + errorMessage.setValue("Failed to delete account: " + + deleteTask.getException().getMessage()); + } + isLoading.setValue(false); + }); + }) + .addOnFailureListener(e -> { + deletionResult.setValue(false); + errorMessage.setValue("Failed to delete user data"); + isLoading.setValue(false); + }); + } else { + deletionResult.setValue(false); + errorMessage.setValue("Current password is incorrect"); + isLoading.setValue(false); + } + }); + } + } + + public void updateDisplayName(String newUsername) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // Validate username format + if (!isValidUsername(newUsername)) { + errorMessage.setValue("Invalid username format"); + return; + } + + // First check if the new username is the same as the current one + User currentUser = userData.getValue(); + if (currentUser != null && currentUser.getUsername().equals(newUsername)) { + errorMessage.setValue("This is already your username"); + return; + } + + // Update username in Firestore + db.collection("users").document(user.getUid()) + .update("username", newUsername) + .addOnSuccessListener(aVoid -> { + // Update the local user data + if (currentUser != null) { + currentUser.setUsername(newUsername); + userData.setValue(currentUser); + } + errorMessage.setValue("Username updated successfully"); + }) + .addOnFailureListener(e -> { + if (e.getMessage().contains("PERMISSION_DENIED")) { + errorMessage.setValue("Permission denied. Please check your Firestore security rules."); + } else { + errorMessage.setValue("Failed to update username: " + e.getMessage()); + } + }); + } + + public void updateDisplayNameOnly(String newDisplayName) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // Update display name in Firestore + db.collection("users").document(user.getUid()) + .update("displayName", newDisplayName) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setDisplayName(newDisplayName); + userData.setValue(currentUser); + } + errorMessage.setValue("Display name updated successfully"); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to update display name: " + e.getMessage()); + }); + } + + public void updateBio(String newBio) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // Validate bio length + if (newBio.length() > 150) { + errorMessage.setValue("Bio cannot be longer than 150 characters"); + return; + } + + // Update bio in Firestore + db.collection("users").document(user.getUid()) + .update("bio", newBio) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setBio(newBio); + userData.setValue(currentUser); + } + errorMessage.setValue("Bio updated successfully"); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to update bio: " + e.getMessage()); + }); + } + + public void updateLocation(String newLocation) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // Validate location length + if (newLocation.length() > 50) { + errorMessage.setValue("Location cannot be longer than 50 characters"); + return; + } + + // Update location in Firestore + db.collection("users").document(user.getUid()) + .update("location", newLocation) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setLocation(newLocation); + userData.setValue(currentUser); + } + errorMessage.setValue("Location updated successfully"); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to update location: " + e.getMessage()); + }); + } + + public void updateInterests(List interests) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + // Update interests in Firestore + db.collection("users").document(user.getUid()) + .update("interests", interests) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setInterests(interests); + userData.setValue(currentUser); + } + errorMessage.setValue("Interests updated successfully"); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to update interests: " + e.getMessage()); + }); + } + + public void followUser(String userIdToFollow) { + FirebaseUser currentAuthUser = mAuth.getCurrentUser(); + if (currentAuthUser == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + String currentUserId = currentAuthUser.getUid(); + + // Don't allow following yourself + if (currentUserId.equals(userIdToFollow)) { + errorMessage.setValue("You cannot follow yourself"); + return; + } + + // Get a reference to the current user's document + db.collection("users").document(currentUserId) + .get() + .addOnSuccessListener(currentUserDoc -> { + User currentUser = currentUserDoc.toObject(User.class); + if (currentUser == null) { + errorMessage.setValue("Could not load current user data"); + return; + } + + // Check if already following + List following = currentUser.getFollowing(); + if (following != null && following.contains(userIdToFollow)) { + errorMessage.setValue("You are already following this user"); + return; + } + + // Add to current user's following list + if (following == null) { + following = new ArrayList<>(); + } + following.add(userIdToFollow); + + // Create a final copy of the following list for the transaction + final List finalFollowing = new ArrayList<>(following); + + // Update Firestore in a transaction + db.runTransaction(transaction -> { + // Update current user's following list + transaction.update(db.collection("users").document(currentUserId), + "following", finalFollowing); + + // Update target user's followers list + db.collection("users").document(userIdToFollow) + .get() + .addOnSuccessListener(targetUserDoc -> { + User targetUser = targetUserDoc.toObject(User.class); + if (targetUser != null) { + List followers = targetUser.getFollowers(); + if (followers == null) { + followers = new ArrayList<>(); + } + followers.add(currentUserId); + db.collection("users").document(userIdToFollow) + .update("followers", followers); + } + }); + + return null; + }).addOnSuccessListener(aVoid -> { + // Update local user data + User updatedUser = userData.getValue(); + if (updatedUser != null) { + updatedUser.setFollowing(finalFollowing); + userData.setValue(updatedUser); + } + errorMessage.setValue("Now following user"); + loadUserData(); // Refresh to get updated counts + }).addOnFailureListener(e -> { + errorMessage.setValue("Failed to follow user: " + e.getMessage()); + }); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Error loading user data: " + e.getMessage()); + }); + } + + public void unfollowUser(String userIdToUnfollow) { + FirebaseUser currentAuthUser = mAuth.getCurrentUser(); + if (currentAuthUser == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + String currentUserId = currentAuthUser.getUid(); + + // Get a reference to the current user's document + db.collection("users").document(currentUserId) + .get() + .addOnSuccessListener(currentUserDoc -> { + User currentUser = currentUserDoc.toObject(User.class); + if (currentUser == null) { + errorMessage.setValue("Could not load current user data"); + return; + } + + // Check if actually following + List following = currentUser.getFollowing(); + if (following == null || !following.contains(userIdToUnfollow)) { + errorMessage.setValue("You are not following this user"); + return; + } + + // Remove from current user's following list + following.remove(userIdToUnfollow); + + // Create a final copy of the following list for the transaction + final List finalFollowing = new ArrayList<>(following); + + // Update Firestore in a transaction + db.runTransaction(transaction -> { + // Update current user's following list + transaction.update(db.collection("users").document(currentUserId), + "following", finalFollowing); + + // Update target user's followers list + db.collection("users").document(userIdToUnfollow) + .get() + .addOnSuccessListener(targetUserDoc -> { + User targetUser = targetUserDoc.toObject(User.class); + if (targetUser != null) { + List followers = targetUser.getFollowers(); + if (followers != null) { + followers.remove(currentUserId); + db.collection("users").document(userIdToUnfollow) + .update("followers", followers); + } + } + }); + + return null; + }).addOnSuccessListener(aVoid -> { + // Update local user data + User updatedUser = userData.getValue(); + if (updatedUser != null) { + updatedUser.setFollowing(finalFollowing); + userData.setValue(updatedUser); + } + errorMessage.setValue("Unfollowed user"); + loadUserData(); // Refresh to get updated counts + }).addOnFailureListener(e -> { + errorMessage.setValue("Failed to unfollow user: " + e.getMessage()); + }); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Error loading user data: " + e.getMessage()); + }); + } + + private boolean isValidUsername(String username) { + // Minimum 3 characters, maximum 30 characters + // Only alphanumeric characters, underscores, and periods + return username != null && username.matches("^[a-zA-Z0-9_.]{3,30}$"); + } + + public void updateProfilePicture(String imageUrl) { + FirebaseUser user = mAuth.getCurrentUser(); + if (user == null) { + errorMessage.setValue("No user is currently signed in"); + return; + } + + isLoading.setValue(true); + + // Create a list with the profile image URL + List profileUrls = new ArrayList<>(); + profileUrls.add(imageUrl); + + // Update profile picture in Firestore + db.collection("users").document(user.getUid()) + .update("profilePictureUrl", profileUrls) + .addOnSuccessListener(aVoid -> { + // Update the local user data + User currentUser = userData.getValue(); + if (currentUser != null) { + currentUser.setProfilePictureUrl(profileUrls); + userData.setValue(currentUser); + } + errorMessage.setValue("Profile picture updated successfully"); + isLoading.setValue(false); + }) + .addOnFailureListener(e -> { + errorMessage.setValue("Failed to update profile picture: " + e.getMessage()); + isLoading.setValue(false); + }); + } + + public LiveData getUserData() { + return userData; + } + + public LiveData getErrorMessage() { + return errorMessage; + } + + public LiveData getIsLoading() { + return isLoading; + } + + public LiveData getDeletionResult() { + return deletionResult; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/profile/UserProfile.java b/app/src/main/java/com/pineapple/capture/profile/UserProfile.java new file mode 100644 index 0000000..c7239e3 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/profile/UserProfile.java @@ -0,0 +1,25 @@ +package com.pineapple.capture.profile; + +public class UserProfile { + private String name; + private String bio; + private String profileImageUrl; + + // Required empty constructor for Firestore + public UserProfile() {} + + public UserProfile(String name, String bio) { + this.name = name; + this.bio = bio; + } + + // Getters and setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBio() { return bio; } + public void setBio(String bio) { this.bio = bio; } + + public String getProfileImageUrl() { return profileImageUrl; } + public void setProfileImageUrl(String profileImageUrl) { this.profileImageUrl = profileImageUrl; } +} \ No newline at end of file diff --git a/app/src/main/java/com/pineapple/capture/utils/CloudinaryManager.java b/app/src/main/java/com/pineapple/capture/utils/CloudinaryManager.java new file mode 100644 index 0000000..4c95549 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/utils/CloudinaryManager.java @@ -0,0 +1,87 @@ +package com.pineapple.capture.utils; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import com.cloudinary.android.MediaManager; +import com.cloudinary.android.callback.ErrorInfo; +import com.cloudinary.android.callback.UploadCallback; +import com.pineapple.capture.BuildConfig; + +import java.util.HashMap; +import java.util.Map; + +public class CloudinaryManager { + + private static final String TAG = "CloudinaryManager"; + private static boolean isInitialized = false; + + public static void init(Context context) { + String cloudName = BuildConfig.CLOUDINARY_CLOUD_NAME; + String apiKey = BuildConfig.CLOUDINARY_API_KEY; + String apiSecret = BuildConfig.CLOUDINARY_API_SECRET; + + if (!isInitialized) { + Map config = new HashMap<>(); + config.put("cloud_name", cloudName); + config.put("api_key", apiKey); + config.put("api_secret", apiSecret); + MediaManager.init(context, config); + isInitialized = true; + } + } + + public static void uploadImage(Uri fileUri, UploadCallback callback) { + MediaManager.get().upload(fileUri) + .option("upload_preset", "unsigned_feed_uploads") + .callback(callback) + .dispatch(); + } + + /** + * Extract the secure URL from the Cloudinary response + * @param resultData The result map from Cloudinary upload + * @return A cleaned and properly formatted image URL + */ + public static String getImageUrl(Map resultData) { + if (resultData == null) { + Log.e(TAG, "Result data is null"); + return ""; + } + + // Try to get the secure URL first (recommended) + String secureUrl = (String) resultData.get("secure_url"); + if (secureUrl != null && !secureUrl.isEmpty()) { + Log.d(TAG, "Using secure_url: " + secureUrl); + return secureUrl; + } + + // Fall back to regular URL if secure is not available + String url = (String) resultData.get("url"); + if (url != null && !url.isEmpty()) { + // Convert to https if it's http + if (url.startsWith("http:")) { + url = url.replace("http:", "https:"); + } + Log.d(TAG, "Using url: " + url); + return url; + } + + // If all else fails, try to construct the URL from public_id + String publicId = (String) resultData.get("public_id"); + if (publicId != null && !publicId.isEmpty()) { + String format = (String) resultData.get("format"); + if (format == null || format.isEmpty()) { + format = "jpg"; // Default format + } + String cloudName = BuildConfig.CLOUDINARY_CLOUD_NAME; + String constructedUrl = "https://res.cloudinary.com/" + cloudName + "/image/upload/" + publicId + "." + format; + Log.d(TAG, "Constructed url: " + constructedUrl); + return constructedUrl; + } + + Log.e(TAG, "Could not extract image URL from: " + resultData); + return ""; + } +} diff --git a/app/src/main/java/com/pineapple/capture/utils/NetworkUtils.java b/app/src/main/java/com/pineapple/capture/utils/NetworkUtils.java new file mode 100644 index 0000000..9728a40 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/utils/NetworkUtils.java @@ -0,0 +1,26 @@ +package com.pineapple.capture.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.os.Build; + +public class NetworkUtils { + public static boolean isConnected(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + + if (cm == null) return false; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Network network = cm.getActiveNetwork(); + if (network == null) return false; + + NetworkCapabilities capabilities = cm.getNetworkCapabilities(network); + return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + } else { + android.net.NetworkInfo networkInfo = cm.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); + } + } +} diff --git a/app/src/main/java/com/pineapple/capture/widget/LatestPostWidget.java b/app/src/main/java/com/pineapple/capture/widget/LatestPostWidget.java new file mode 100644 index 0000000..d928644 --- /dev/null +++ b/app/src/main/java/com/pineapple/capture/widget/LatestPostWidget.java @@ -0,0 +1,177 @@ +package com.pineapple.capture.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; +import android.os.Bundle; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.target.AppWidgetTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.Query; +import com.google.firebase.firestore.QueryDocumentSnapshot; +import com.pineapple.capture.MainActivity; +import com.pineapple.capture.R; +import com.pineapple.capture.feed.FeedItem; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class LatestPostWidget extends AppWidgetProvider { + + private static final String TAG = "LatestPostWidget"; + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + // There may be multiple widgets active, so update all of them + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } + + private void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_latest_post); + + // Show loading state - hide post views, show empty view with loading text + views.setViewVisibility(R.id.widget_empty_view, View.VISIBLE); + views.setTextViewText(R.id.widget_empty_view, "Loading..."); + + // Set up click intent for the widget + Intent intent = new Intent(context, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + views.setOnClickPendingIntent(R.id.widget_post_image, pendingIntent); + + // Update the widget initially with loading state + appWidgetManager.updateAppWidget(appWidgetId, views); + + // Fetch the latest post from Firestore + FirebaseFirestore db = FirebaseFirestore.getInstance(); + db.collection("posts") + .orderBy("timestamp", Query.Direction.DESCENDING) + .limit(1) + .get() + .addOnSuccessListener(queryDocumentSnapshots -> { + if (!queryDocumentSnapshots.isEmpty()) { + for (QueryDocumentSnapshot document : queryDocumentSnapshots) { + FeedItem latestPost = document.toObject(FeedItem.class); + latestPost.setId(document.getId()); + + // Update widget with post data + updateWidgetWithPost(context, appWidgetManager, appWidgetId, latestPost); + return; + } + } else { + // No posts found + views.setViewVisibility(R.id.widget_empty_view, View.VISIBLE); + views.setTextViewText(R.id.widget_empty_view, "No posts available"); + appWidgetManager.updateAppWidget(appWidgetId, views); + } + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Error fetching latest post", e); + views.setViewVisibility(R.id.widget_empty_view, View.VISIBLE); + views.setTextViewText(R.id.widget_empty_view, "Error loading post"); + appWidgetManager.updateAppWidget(appWidgetId, views); + }); + } + + private void updateWidgetWithPost(Context context, AppWidgetManager appWidgetManager, + int appWidgetId, FeedItem post) { + // Create a RemoteViews object + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_latest_post); + + // Hide empty view + views.setViewVisibility(R.id.widget_empty_view, View.GONE); + + // Set caption + if (post.getContent() != null && !post.getContent().isEmpty()) { + views.setTextViewText(R.id.widget_post_caption, post.getContent()); + views.setViewVisibility(R.id.widget_post_caption, View.VISIBLE); + } else { + views.setViewVisibility(R.id.widget_post_caption, View.GONE); + } + + // Set username + views.setTextViewText(R.id.widget_username, post.getUsername() != null ? + post.getUsername() : "Anonymous"); + + // Set up click intent to open the app + Intent intent = new Intent(context, MainActivity.class); + intent.putExtra("post_id", post.getId()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + views.setOnClickPendingIntent(R.id.widget_post_image, pendingIntent); + + // First update with what we have (without images) + appWidgetManager.updateAppWidget(appWidgetId, views); + + // Load images in background thread + ExecutorService executor = Executors.newSingleThreadExecutor(); + Handler handler = new Handler(Looper.getMainLooper()); + + executor.execute(() -> { + try { + // Load post image with Glide in background thread + if (post.getImageUrl() != null && !post.getImageUrl().isEmpty()) { + try { + Bitmap bitmap = Glide.with(context.getApplicationContext()) + .asBitmap() + .load(post.getImageUrl()) + .submit(400, 400) // Limit size to prevent OOM + .get(); + + views.setImageViewBitmap(R.id.widget_post_image, bitmap); + } catch (Exception e) { + Log.e(TAG, "Error loading post image", e); + } + } + + // Load user avatar with Glide in background thread + if (post.getProfilePictureUrl() != null && !post.getProfilePictureUrl().isEmpty()) { + try { + Bitmap avatarBitmap = Glide.with(context.getApplicationContext()) + .asBitmap() + .load(post.getProfilePictureUrl()) + .circleCrop() + .submit(80, 80) // Small size for avatar + .get(); + + views.setImageViewBitmap(R.id.widget_user_avatar, avatarBitmap); + } catch (Exception e) { + Log.e(TAG, "Error loading avatar image", e); + } + } + + // Update widget with images on main thread + handler.post(() -> { + appWidgetManager.updateAppWidget(appWidgetId, views); + }); + } catch (Exception e) { + Log.e(TAG, "Error in background loading", e); + } + }); + } + + @Override + public void onEnabled(Context context) { + // Called when the first widget is created + } + + @Override + public void onDisabled(Context context) { + // Called when the last widget is disabled + + // Shutdown the executor service if needed + } +} \ No newline at end of file diff --git a/app/src/main/res/color/nav_item_color.xml b/app/src/main/res/color/nav_item_color.xml new file mode 100644 index 0000000..982c09f --- /dev/null +++ b/app/src/main/res/color/nav_item_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/nav_item_color_enhanced.xml b/app/src/main/res/color/nav_item_color_enhanced.xml new file mode 100644 index 0000000..c724862 --- /dev/null +++ b/app/src/main/res/color/nav_item_color_enhanced.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_bottom_nav.xml b/app/src/main/res/drawable/bg_bottom_nav.xml new file mode 100644 index 0000000..77c1cb4 --- /dev/null +++ b/app/src/main/res/drawable/bg_bottom_nav.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bottom_sheet_background.xml b/app/src/main/res/drawable/bottom_sheet_background.xml new file mode 100644 index 0000000..40cd998 --- /dev/null +++ b/app/src/main/res/drawable/bottom_sheet_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/camera_nav_icon.png b/app/src/main/res/drawable/camera_nav_icon.png new file mode 100644 index 0000000..e3ee166 Binary files /dev/null and b/app/src/main/res/drawable/camera_nav_icon.png differ diff --git a/app/src/main/res/drawable/camera_nav_scaled.xml b/app/src/main/res/drawable/camera_nav_scaled.xml new file mode 100644 index 0000000..e513c17 --- /dev/null +++ b/app/src/main/res/drawable/camera_nav_scaled.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/caption_input_background.xml b/app/src/main/res/drawable/caption_input_background.xml new file mode 100644 index 0000000..7c4ade9 --- /dev/null +++ b/app/src/main/res/drawable/caption_input_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..5be3534 --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_border.xml b/app/src/main/res/drawable/circle_border.xml new file mode 100644 index 0000000..7cb1f72 --- /dev/null +++ b/app/src/main/res/drawable/circle_border.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/circle_button_background.xml b/app/src/main/res/drawable/circle_button_background.xml new file mode 100644 index 0000000..c798c76 --- /dev/null +++ b/app/src/main/res/drawable/circle_button_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/default_profile.xml b/app/src/main/res/drawable/default_profile.xml new file mode 100644 index 0000000..27d1d4f --- /dev/null +++ b/app/src/main/res/drawable/default_profile.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/error_image.xml b/app/src/main/res/drawable/error_image.xml new file mode 100644 index 0000000..eb9e70b --- /dev/null +++ b/app/src/main/res/drawable/error_image.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_art.xml b/app/src/main/res/drawable/ic_art.xml new file mode 100644 index 0000000..f1668a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_art.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..791df00 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..80c9161 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml new file mode 100644 index 0000000..4b35725 --- /dev/null +++ b/app/src/main/res/drawable/ic_comment.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_creativity.xml b/app/src/main/res/drawable/ic_creativity.xml new file mode 100644 index 0000000..25666dd --- /dev/null +++ b/app/src/main/res/drawable/ic_creativity.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_dancing.xml b/app/src/main/res/drawable/ic_dancing.xml new file mode 100644 index 0000000..e45ef07 --- /dev/null +++ b/app/src/main/res/drawable/ic_dancing.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..cd0652e --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..1520ada --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_empty_feed.xml b/app/src/main/res/drawable/ic_empty_feed.xml new file mode 100644 index 0000000..736565d --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_feed.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml new file mode 100644 index 0000000..55b4ee1 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_favorite_border.xml b/app/src/main/res/drawable/ic_favorite_border.xml new file mode 100644 index 0000000..6910629 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_flash_off.xml b/app/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 0000000..21c87c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 0000000..5c50afc --- /dev/null +++ b/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..665b7e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_home_wrapper.xml b/app/src/main/res/drawable/ic_home_wrapper.xml new file mode 100644 index 0000000..7e580e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_wrapper.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..ca3826a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_location.xml b/app/src/main/res/drawable/ic_location.xml new file mode 100644 index 0000000..195d6b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_location.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..ac0aa96 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_log_out.png b/app/src/main/res/drawable/ic_log_out.png new file mode 100644 index 0000000..bdff774 Binary files /dev/null and b/app/src/main/res/drawable/ic_log_out.png differ diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..b1ccbef --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..a910cb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_music.xml b/app/src/main/res/drawable/ic_music.xml new file mode 100644 index 0000000..e0f6a89 --- /dev/null +++ b/app/src/main/res/drawable/ic_music.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..6f30c6e --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pets.xml b/app/src/main/res/drawable/ic_pets.xml new file mode 100644 index 0000000..31f495f --- /dev/null +++ b/app/src/main/res/drawable/ic_pets.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_photography.xml b/app/src/main/res/drawable/ic_photography.xml new file mode 100644 index 0000000..d48a0df --- /dev/null +++ b/app/src/main/res/drawable/ic_photography.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_profile.xml b/app/src/main/res/drawable/ic_profile.xml new file mode 100644 index 0000000..209eb2f --- /dev/null +++ b/app/src/main/res/drawable/ic_profile.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_profile_wrapper.xml b/app/src/main/res/drawable/ic_profile_wrapper.xml new file mode 100644 index 0000000..e458e25 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_wrapper.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..4bbacb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..1d22581 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sports.xml b/app/src/main/res/drawable/ic_sports.xml new file mode 100644 index 0000000..c5d86b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sports.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_switch_camera.xml b/app/src/main/res/drawable/ic_switch_camera.xml new file mode 100644 index 0000000..39917de --- /dev/null +++ b/app/src/main/res/drawable/ic_switch_camera.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/nav_bar_background.xml b/app/src/main/res/drawable/nav_bar_background.xml new file mode 100644 index 0000000..6991e3f --- /dev/null +++ b/app/src/main/res/drawable/nav_bar_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/nav_item_background.xml b/app/src/main/res/drawable/nav_item_background.xml new file mode 100644 index 0000000..e68d5d4 --- /dev/null +++ b/app/src/main/res/drawable/nav_item_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/placeholder_image.xml b/app/src/main/res/drawable/placeholder_image.xml new file mode 100644 index 0000000..4a0ea4d --- /dev/null +++ b/app/src/main/res/drawable/placeholder_image.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/post_card_background.xml b/app/src/main/res/drawable/post_card_background.xml new file mode 100644 index 0000000..416fb9f --- /dev/null +++ b/app/src/main/res/drawable/post_card_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/post_card_background_elevated.xml b/app/src/main/res/drawable/post_card_background_elevated.xml new file mode 100644 index 0000000..4c4cf05 --- /dev/null +++ b/app/src/main/res/drawable/post_card_background_elevated.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_button_accent.xml b/app/src/main/res/drawable/rounded_button_accent.xml new file mode 100644 index 0000000..4cc846e --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_accent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_button_blue.xml b/app/src/main/res/drawable/rounded_button_blue.xml new file mode 100644 index 0000000..77238c9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_button_dark.xml b/app/src/main/res/drawable/rounded_button_dark.xml new file mode 100644 index 0000000..d4b1b0a --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_button_outline.xml b/app/src/main/res/drawable/rounded_button_outline.xml new file mode 100644 index 0000000..b0d39e2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_outline.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_button_secondary.xml b/app/src/main/res/drawable/rounded_button_secondary.xml new file mode 100644 index 0000000..c766894 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_secondary.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_corner_background.xml b/app/src/main/res/drawable/rounded_corner_background.xml new file mode 100644 index 0000000..3111a8d --- /dev/null +++ b/app/src/main/res/drawable/rounded_corner_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_edittext_dark.xml b/app/src/main/res/drawable/rounded_edittext_dark.xml new file mode 100644 index 0000000..5da8432 --- /dev/null +++ b/app/src/main/res/drawable/rounded_edittext_dark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/sample_post.png b/app/src/main/res/drawable/sample_post.png new file mode 100644 index 0000000..eee2511 Binary files /dev/null and b/app/src/main/res/drawable/sample_post.png differ diff --git a/app/src/main/res/drawable/timestamp_background.xml b/app/src/main/res/drawable/timestamp_background.xml new file mode 100644 index 0000000..ca7a4f4 --- /dev/null +++ b/app/src/main/res/drawable/timestamp_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..5bced41 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_preview.xml b/app/src/main/res/drawable/widget_preview.xml new file mode 100644 index 0000000..ba9ac3f --- /dev/null +++ b/app/src/main/res/drawable/widget_preview.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/fonts.xml b/app/src/main/res/font/fonts.xml new file mode 100644 index 0000000..61b5847 --- /dev/null +++ b/app/src/main/res/font/fonts.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/geist.otf b/app/src/main/res/font/geist.otf new file mode 100644 index 0000000..14d10fc Binary files /dev/null and b/app/src/main/res/font/geist.otf differ diff --git a/app/src/main/res/layout/activity_auth.xml b/app/src/main/res/layout/activity_auth.xml new file mode 100644 index 0000000..b87ff71 --- /dev/null +++ b/app/src/main/res/layout/activity_auth.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_friends.xml b/app/src/main/res/layout/activity_friends.xml new file mode 100644 index 0000000..e51c96d --- /dev/null +++ b/app/src/main/res/layout/activity_friends.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_interests.xml b/app/src/main/res/layout/activity_interests.xml new file mode 100644 index 0000000..cce1a09 --- /dev/null +++ b/app/src/main/res/layout/activity_interests.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +