diff --git a/.gitignore b/.gitignore
index 7278a41..e2a52fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,8 @@ app.*.map.json
*.base64.txt
android/app/release-key.jks
android/app/upload-keystore.jks
-*.txt
\ No newline at end of file
+*.txt
+*.apk
+/nightly
+.pub-cache-hash
+delete-releases.js
diff --git a/BUILD_README.md b/BUILD_README.md
new file mode 100644
index 0000000..dc25afe
--- /dev/null
+++ b/BUILD_README.md
@@ -0,0 +1,628 @@
+# Playtivity Local APK Builder & Releaser
+
+This directory contains Node.js scripts to build and release Playtivity APK files locally with automatic version incrementing and proper release signing.
+
+## ๐ Quick Start
+
+```bash
+# List all available commands
+npm run list
+
+# Quick development build
+npm run build
+
+# Production release
+npm run release
+
+# Latest development with nightly branding
+npm run nightly
+```
+
+### Development Builds (Testing)
+```bash
+# Quick development build (increment build number only)
+node build-apk.js # or: npm run build
+```
+
+### Release Builds (Distribution)
+```bash
+# Production release (increment patch version, signed APK + AAB)
+node release-apk.js # or: npm run release
+```
+
+### Nightly Builds (Latest Development)
+```bash
+# Latest development build with nightly branding
+node nightly-apk.js # or: npm run nightly
+```
+
+### Nightly Release Promotion
+```bash
+# Promote tested nightly build to official release
+node nightly-release.js # or: npm run nightly:promote
+```
+
+### Nightly GitHub Release
+```bash
+# Create GitHub release from nightly build
+node nightly-github-release.js # or: npm run nightly:github
+```
+
+## ๐ฑ Build Types Comparison
+
+| Feature | Development Build | Release Build | Nightly Build | Nightly Promotion | Nightly GitHub Release |
+|---------|------------------|---------------|---------------|-------------------|----------------------|
+| **Purpose** | Testing, development | Distribution, production | Latest development features | Convert nightly to release | Share nightly on GitHub |
+| **Signing** | Debug signing | Release keystore signing | Debug signing | Release keystore signing | Debug signing |
+| **Version** | Increments build number | Increments version number | Special nightly versioning | Strips nightly, increments version | Uses nightly version |
+| **Output** | `builds/` folder | `releases/` folder | `nightly/` folder | `releases/` folder | GitHub release |
+| **Files** | APK only | APK + AAB + release notes | APK + detailed build info | APK + AAB + release notes | APK + release notes |
+| **Stability** | Stable development | Production ready | **Unstable - may have bugs** | Production ready (tested nightly) | **Unstable - may have bugs** |
+| **Branding** | Normal | Official release | **NIGHTLY DEVELOPMENT BUILD** | Official release | **NIGHTLY DEVELOPMENT BUILD** |
+
+## ๐ ๏ธ Development Build Usage
+
+### Node.js
+```bash
+# Quick build (increment build number: 0.0.1+1 โ 0.0.1+2)
+node build-apk.js
+
+# Increment version parts
+node build-apk.js patch # 0.0.1+5 โ 0.0.2+1
+node build-apk.js minor # 0.1.2+3 โ 0.2.0+1
+node build-apk.js major # 1.2.3+4 โ 2.0.0+1
+```
+
+### NPM Scripts
+```bash
+npm run build # Default build
+npm run build:patch # Patch version
+npm run build:minor # Minor version
+npm run build:major # Major version
+npm run help # Show help
+```
+
+### NPM Scripts
+```bash
+npm run build # Default build
+npm run build:patch # Patch version
+npm run build:minor # Minor version
+npm run build:major # Major version
+```
+
+### PowerShell
+```powershell
+.\build-apk.ps1 # Default build
+.\build-apk.ps1 patch # Patch version
+```
+
+## ๐ Release Build Usage
+
+### Node.js
+```bash
+# Standard release (increment patch: 0.0.1 โ 0.0.2)
+node release-apk.js
+
+# Different version increments
+node release-apk.js minor # 0.1.0 โ 0.2.0
+node release-apk.js major # 1.0.0 โ 2.0.0
+node release-apk.js none # Don't increment version
+
+# APK-only release (skip AAB bundle)
+node release-apk.js --no-bundle
+```
+
+### NPM Scripts
+```bash
+npm run release # Standard release
+npm run release:patch # Patch version
+npm run release:minor # Minor version
+npm run release:major # Major version
+npm run release:apk-only # APK only, no AAB
+```
+
+### PowerShell
+```powershell
+.\release-apk.ps1 # Standard release
+.\release-apk.ps1 minor # Minor version
+.\release-apk.ps1 -NoBundle # APK only
+```
+
+## ๐ Nightly Build Usage
+
+**โ ๏ธ WARNING: Nightly builds are DEVELOPMENT BUILDS and may be unstable!**
+
+### Node.js
+```bash
+# Create nightly build from latest development code
+node nightly-apk.js
+
+# Keep more old nightly builds
+node nightly-apk.js --keep-builds 10
+```
+
+### NPM Scripts
+```bash
+npm run nightly # Standard nightly build
+npm run nightly:keep-10 # Keep 10 old builds
+```
+
+### Nightly Build Features
+- **Special Versioning**: `0.0.1-nightly-20250614-143022+timestamp`
+- **Git Integration**: Includes branch, commit hash, and commit message
+- **Build Metadata**: Detailed JSON build information
+- **Auto-cleanup**: Automatically removes old nightly builds
+- **Clear Branding**: Explicitly marked as NIGHTLY DEVELOPMENT BUILD
+
+## ๐ Nightly Release Promotion
+
+After testing a nightly build and confirming it's stable, you can promote it to an official release:
+
+**โ ๏ธ IMPORTANT: This feature converts a tested nightly build into a production release!**
+
+### Node.js
+```bash
+# Promote latest nightly to release (patch increment)
+node nightly-release.js
+
+# Promote with version increment type
+node nightly-release.js patch # 0.0.1-nightly-... โ 0.0.2+1
+node nightly-release.js minor # 0.1.0-nightly-... โ 0.2.0+1
+node nightly-release.js major # 1.0.0-nightly-... โ 2.0.0+1
+
+# Promote specific nightly build
+node nightly-release.js --build-id 20250614-143022
+
+# Custom version for release
+node nightly-release.js --version 1.5.0
+```
+
+### NPM Scripts
+```bash
+npm run nightly:promote # Promote latest (patch increment)
+npm run nightly:promote-patch # Patch increment
+npm run nightly:promote-minor # Minor increment
+npm run nightly:promote-major # Major increment
+```
+
+### PowerShell
+```powershell
+.\nightly-release.ps1 # Promote latest (patch increment)
+.\nightly-release.ps1 patch # Patch increment
+.\nightly-release.ps1 minor # Minor increment
+.\nightly-release.ps1 major # Major increment
+.\nightly-release.ps1 -BuildId "20250614-143022" # Specific build
+.\nightly-release.ps1 -Version "1.5.0" # Custom version
+```
+
+### Promotion Process
+1. **Lists Available Nightly Builds**: Shows all nightly builds with creation dates and sizes
+2. **Selects Build**: Uses latest by default, or specify with `--build-id`
+3. **Creates Release Version**: Strips `-nightly-` suffix and increments version
+4. **Updates pubspec.yaml**: Sets new release version
+5. **Builds Signed APK + AAB**: Creates production-ready files with release keystore
+6. **Generates Release Notes**: Includes original nightly build info and git details
+7. **Stores in releases/**: Organized alongside other official releases
+
+### Example Promotion
+```bash
+# Before: 0.1.0-nightly-20250614-143022+1718373622
+# After: 0.1.1+1 (official release)
+```
+
+## ๐ Development Workflow Examples
+
+### Standard Development Cycle
+```bash
+# 1. Quick testing during development
+npm run build # Create development builds for testing
+
+# 2. Ready for release
+npm run release # Create official release when stable
+```
+
+### Nightly Development Cycle
+```bash
+# 1. Create nightly with latest development code
+npm run nightly # Build with nightly branding + git tracking
+
+# 2. Test the nightly build thoroughly
+# (Install and test the APK from nightly/ folder)
+
+# 3. If nightly is stable, promote to official release
+npm run nightly:promote # Convert tested nightly to official release
+```
+
+### Nightly GitHub Release Cycle
+```bash
+# 1. Create nightly with latest development code
+npm run nightly # Build with nightly branding + git tracking
+
+# 2. Share nightly build on GitHub for community testing
+npm run nightly:github # Upload to GitHub releases as prerelease
+
+# 3. (Optional) After community testing, promote to official release
+npm run nightly:promote # Convert tested nightly to official release
+```
+
+### Recommended Workflow
+1. **Daily Development**: Use `npm run build` for rapid testing
+2. **Weekly Nightly**: Use `npm run nightly` to test latest development code
+3. **Monthly Releases**: Use `npm run release` or promote tested nightly builds
+4. **Emergency Fixes**: Use `npm run release patch` for quick bug fixes
+
+### GitHub Release Workflow
+1. **Create Nightly**: Use `npm run nightly` to build with git tracking
+2. **Share for Testing**: Use `npm run nightly:github` to upload to GitHub
+3. **Community Feedback**: Let testers download and provide feedback
+4. **Promote if Stable**: Use `npm run nightly:promote` for official release
+
+## ๐ง GitHub Release Setup
+
+### 1. Install GitHub CLI
+Download from: https://cli.github.com/
+
+Or with PowerShell:
+```powershell
+winget install GitHub.cli
+```
+
+Or with Chocolatey:
+```powershell
+choco install gh
+```
+
+### 2. Authenticate with GitHub
+```bash
+gh auth login
+```
+
+This will open a web browser to authenticate with GitHub. Choose your preferred authentication method.
+
+## ๐ฑ Version Increment Types
+
+| Type | Example Change | When to Use |
+|------|----------------|-------------|
+| `build` (default) | `0.0.1+1` โ `0.0.1+2` | Testing, development builds |
+| `patch` | `0.0.1+5` โ `0.0.2+1` | Bug fixes |
+| `minor` | `0.1.2+3` โ `0.2.0+1` | New features |
+| `major` | `1.2.3+4` โ `2.0.0+1` | Breaking changes |
+
+## ๐ ๏ธ Usage Examples
+
+### Node.js
+```bash
+# Quick build (increment build number)
+node build-apk.js
+
+# Increment patch version
+node build-apk.js patch
+
+# Increment minor version
+node build-apk.js minor
+
+# Increment major version
+node build-apk.js major
+
+# Show help
+node build-apk.js --help
+```
+
+### NPM Scripts
+```bash
+npm run build # Default build
+npm run build:patch # Patch version
+npm run build:minor # Minor version
+npm run build:major # Major version
+npm run help # Show help
+```
+
+### Nightly GitHub Release Examples
+```bash
+# Create and share latest nightly on GitHub
+npm run nightly && npm run nightly:github
+
+# Share specific nightly build
+npm run nightly:github # Latest nightly
+node nightly-github-release.js --build-id 20250614-143022 # Specific build
+
+# Mark nightly as stable release (not prerelease)
+npm run nightly:github-stable
+```
+
+## ๐๏ธ Release Build Usage
+
+### Node.js
+```bash
+# Default release build (patch increment)
+node release-apk.js
+
+# Increment version types
+node release-apk.js patch # 0.0.1+5 โ 0.0.2+1
+node release-apk.js minor # 0.1.2+3 โ 0.2.0+1
+node release-apk.js major # 1.2.3+4 โ 2.0.0+1
+
+# APK only (skip AAB bundle)
+node release-apk.js --no-bundle
+```
+
+### NPM Scripts
+```bash
+npm run release # Default release (patch)
+npm run release:patch # Patch version
+npm run release:minor # Minor version
+npm run release:major # Major version
+npm run release:apk-only # APK only, no AAB
+```
+
+### PowerShell
+```powershell
+.\build-apk.ps1 # Default build
+.\build-apk.ps1 patch # Patch version
+.\build-apk.ps1 minor # Minor version
+.\build-apk.ps1 major # Major version
+.\build-apk.ps1 -Help # Show help
+```
+
+## ๐ Output Structure
+
+### Development Builds (`builds/` folder)
+```
+builds/
+โโโ playtivity-v0.0.1-build2-2025-06-14-1430.apk # Timestamped APK
+โโโ playtivity-v0.0.1-build3-2025-06-14-1445.apk # Another build
+โโโ playtivity-latest.apk # Always points to latest build
+```
+
+### Release Builds (`releases/` folder)
+```
+releases/
+โโโ playtivity-v0.0.2-release.apk # Signed release APK
+โโโ playtivity-v0.0.2-release.aab # Signed App Bundle (for Play Store)
+โโโ release-notes-v0.0.2.md # Generated release notes
+โโโ playtivity-v0.0.3-release.apk # Next release
+โโโ release-notes-v0.0.3.md # Next release notes
+```
+
+### Nightly Builds (`nightly/` folder)
+```
+nightly/
+โโโ playtivity-nightly-20250614-143022-a1b2c3d.apk # Timestamped nightly APK
+โโโ playtivity-latest-nightly.apk # Always points to latest nightly
+โโโ nightly-info-20250614-143022.json # Build metadata with git info
+โโโ nightly-notes-20250614-143022.md # Detailed build notes
+โโโ latest-nightly-info.json # Latest build metadata
+โโโ latest-nightly-notes.md # Latest build notes
+```
+
+### Nightly Release Promotion
+When you promote a nightly build using `nightly-release.js`, it:
+1. **Reads nightly build metadata** from `nightly/` folder
+2. **Creates official release** in `releases/` folder with proper versioning
+3. **Strips nightly branding** and creates production-ready APK + AAB
+4. **Preserves git history** from original nightly build in release notes
+5. **Updates pubspec.yaml** with new release version
+โโโ nightly-info-20250614-143022.json # Build metadata
+โโโ nightly-notes-20250614-143022.md # Build documentation
+โโโ playtivity-latest-nightly.apk # Latest nightly build
+โโโ latest-nightly-info.json # Latest build info
+โโโ latest-nightly-notes.md # Latest build notes
+```
+
+## ๐ Release Signing Setup
+
+Release builds require a keystore for signing. The scripts handle this automatically:
+
+### Automatic Setup
+1. **Base64 Keystore**: If `keystore.base64.txt` exists, it's decoded to `release-key.jks`
+2. **Existing Keystore**: If `release-key.jks` exists, it's used directly
+3. **Environment Variables**: Credentials can be set via environment variables
+
+### Environment Variables (Optional)
+```bash
+# Windows Command Prompt
+set ANDROID_KEYSTORE_PASSWORD=your_password
+set ANDROID_KEY_ALIAS=your_alias
+set ANDROID_KEY_PASSWORD=your_key_password
+
+# PowerShell
+$env:ANDROID_KEYSTORE_PASSWORD="your_password"
+$env:ANDROID_KEY_ALIAS="your_alias"
+$env:ANDROID_KEY_PASSWORD="your_key_password"
+```
+
+### Default Credentials
+If no environment variables are set, defaults are used:
+- **Keystore Password**: `playtivity123`
+- **Key Alias**: `playtivity-key`
+- **Key Password**: `playtivity123`
+
+## ๐ฑ Installing on Device
+
+### Development Builds
+```bash
+# Install latest development build
+adb install "builds/playtivity-latest.apk"
+```
+
+### Release Builds
+```bash
+# Install latest release build
+adb install "releases/playtivity-v0.0.2-release.apk"
+```
+
+### Nightly Builds
+```bash
+# Install latest nightly build
+adb install "nightly/playtivity-latest-nightly.apk"
+
+# Install specific nightly build
+adb install "nightly/playtivity-nightly-20250614-143022-a1b2c3d.apk"
+```
+
+### Manual Installation
+1. Transfer the APK file to your Android device
+2. Enable "Install from unknown sources" in Android settings
+3. Open the APK file and follow installation prompts
+
+## โ๏ธ What the Scripts Do
+
+### Development Build Process (`build-apk.js`)
+1. **๐ Read Current Version**: Parse version from `pubspec.yaml`
+2. **๐ข Increment Build Number**: Increment build number only (for testing)
+3. **๐พ Update pubspec.yaml**: Write new version back to file
+4. **๐งน Clean Build**: Run `flutter clean` to ensure fresh build
+5. **๐ฆ Get Dependencies**: Run `flutter pub get`
+6. **๐งช Run Tests**: Execute tests (continues on failure)
+7. **๐จ Build APK**: Create debug APK with `flutter build apk --release`
+8. **๐ Copy & Organize**: Copy APK to `builds/` with timestamp
+
+### Release Build Process (`release-apk.js`)
+1. **๐ Setup Keystore**: Prepare release signing keystore
+2. **๐ Read Current Version**: Parse version from `pubspec.yaml`
+3. **๐ข Increment Version**: Increment version number (patch/minor/major)
+4. **๐พ Update pubspec.yaml**: Write new version back to file
+5. **๐งน Clean Build**: Run `flutter clean` to ensure fresh build
+6. **๐ฆ Get Dependencies**: Run `flutter pub get`
+7. **๐งช Run Tests**: Execute tests (continues on failure)
+8. **๐จ Build Signed APK**: Create signed APK with release keystore
+9. **๐ฆ Build AAB Bundle**: Create signed App Bundle for Play Store
+10. **๐ Verify Signatures**: Validate APK signing and integrity
+11. **๐ Copy & Organize**: Copy files to `releases/` folder
+12. **๐งฎ Generate Checksums**: Create SHA256 checksums for verification
+13. **๐ Create Release Notes**: Generate markdown release documentation
+
+## ๐ง Requirements
+
+### For All Scripts:
+- Node.js 14.0.0 or higher
+- Flutter SDK (or FVM)
+- Android SDK (for release signing verification)
+- **Java 21** (required for Android builds - configured in `android/app/build.gradle.kts`)
+
+### For Release Builds (Additional):
+- Valid Android keystore (`release-key.jks` or `keystore.base64.txt`)
+- Android SDK Build Tools (for APK verification)
+
+### For GitHub Releases (Additional):
+- GitHub CLI (`gh`) installed and authenticated
+- GitHub repository with write access
+
+## ๐ฏ When to Use Each Script
+
+### Use Development Builds (`build-apk.js`) When:
+- ๐งช Testing new features
+- ๐ Debugging issues
+- ๐ Rapid iteration during development
+- ๐ฑ Installing on your own device for testing
+
+### Use Release Builds (`release-apk.js`) When:
+- ๐ Creating production releases
+- ๐ค Distributing to beta testers
+- ๐ช Uploading to app stores
+- ๐ Creating official version releases
+- ๐ Need properly signed APKs
+
+### Use Nightly Builds (`nightly-apk.js`) When:
+- ๐ Want to test the absolute latest development code
+- ๐ฌ Need to verify recent commits work
+- ๐ฅ Sharing bleeding-edge builds with testers
+- ๐ Creating automated development builds
+- โก Want git commit tracking in builds
+
+### Use Nightly Release Promotion (`nightly-release.js`) When:
+- โ
You've tested a nightly build and confirmed it's stable
+- ๐ Ready to create an official release from tested nightly code
+- ๐ Want to maintain git history from nightly to release
+- ๐ Converting development build to production release
+- ๐ฆ Need signed APK + AAB for distribution after nightly testing
+
+### Use Nightly GitHub Release (`nightly-github-release.js`) When:
+- ๐ค Want to share nightly builds publicly on GitHub
+- ๐ฅ Distributing development builds to testers via GitHub
+- ๐ Creating automated nightly release pipeline
+- ๐ Need permanent download links for nightly builds
+- ๐ Want to track nightly releases with git history
+- ๐ Making bleeding-edge builds available to community
+
+**โ ๏ธ IMPORTANT: Nightly builds are development builds and may be unstable!**
+
+## ๐ฏ Why Auto-Increment Version?
+
+Android requires a higher version code (build number) for app updates. These scripts ensure:
+- โ
No version conflicts when installing on device
+- โ
Proper app updates (Android won't install lower version codes)
+- โ
Easy tracking of builds with timestamps
+- โ
Consistent versioning across development team
+
+## ๐ Troubleshooting
+
+### "Flutter not found"
+- Ensure Flutter SDK is in your PATH
+- Or install and use FVM: `dart pub global activate fvm`
+
+### "Build failed"
+- Check that you're in the project root directory
+- Ensure `pubspec.yaml` exists
+- Run `flutter doctor` to check Flutter installation
+
+### "Permission denied" (PowerShell)
+```powershell
+# Allow script execution (run as Administrator)
+Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
+```
+
+### "APK won't install on device"
+- Enable "Install from unknown sources" in Android settings
+- Uninstall previous version if downgrading
+- Check available storage space
+
+### "No nightly builds found" (Nightly Promotion)
+- Run `npm run nightly` first to create nightly builds
+- Check that `nightly/` folder exists with APK files
+- Verify nightly build completed successfully
+
+### "Keystore not found" (Release/Nightly Promotion)
+- Ensure `release-key.jks` exists in project root
+- Or provide `keystore.base64.txt` for automatic keystore setup
+- Check keystore permissions and file integrity
+
+### "GitHub CLI not found" (GitHub Release)
+- Install GitHub CLI from https://cli.github.com/
+- Or install with: `winget install GitHub.cli`
+- Ensure `gh` is in your PATH after installation
+- Authenticate with: `gh auth login`
+
+### "GitHub CLI not authenticated" (GitHub Release)
+- Run `gh auth login` to authenticate
+- Follow the prompts to authenticate via web browser
+- Verify authentication with: `gh auth status`
+
+### "Permission denied" (GitHub Release)
+- Verify you have write access to the repository
+- Check that you're the owner or have collaborator access
+- Ensure the repository exists and is accessible
+- Re-authenticate with: `gh auth login`
+
+## ๐ Notes
+
+- Version increments are permanent (modifies `pubspec.yaml`)
+- Build artifacts are stored in `builds/` directory
+- Release artifacts are stored in `releases/` directory
+- Nightly artifacts are stored in `nightly/` directory
+- Scripts automatically detect and use FVM if available
+- Tests run but don't fail the build (continues on error)
+- Each build creates both timestamped and "latest" APK files
+- Nightly release promotion preserves original build metadata
+- Keystore setup is automatic if `keystore.base64.txt` exists
+- All scripts are Node.js-based for cross-platform compatibility
+- GitHub releases require GitHub CLI authentication
+- GitHub releases are marked as prerelease by default (use `--stable` to override)
+
+## ๐ค Contributing
+
+To modify the build scripts:
+1. Edit the relevant `.js` files for functionality changes
+2. Update this README if adding new features
+3. Test on different environments before committing
+4. Ensure Node.js compatibility across platforms
diff --git a/WIDGET_CLICKABLE_IMPROVEMENTS.md b/WIDGET_CLICKABLE_IMPROVEMENTS.md
new file mode 100644
index 0000000..ed42d45
--- /dev/null
+++ b/WIDGET_CLICKABLE_IMPROVEMENTS.md
@@ -0,0 +1,112 @@
+# Widget Clickable Improvements
+
+## Overview
+Enhanced the home widget to make activity items clickable, allowing users to open friend's Spotify profiles directly from the widget.
+
+## Changes Made
+
+### 1. Android Widget Improvements
+
+#### `PlaytivityWidgetProvider.kt`
+- **Added `userId` field** to `ActivityItem` data class to store friend's user ID
+- **Updated data loading** to include user IDs from SharedPreferences
+- **Implemented click template** using `setPendingIntentTemplate` for ListView items
+- **Added fill-in intents** for individual activity items with user ID and friend name
+- **Made entire activity item clickable** by adding click handler to the root layout
+
+#### `widget_activity_item.xml`
+- **Added ID and clickable attributes** to the root LinearLayout
+- **Enabled focus and click handling** for better touch feedback
+
+#### `MainActivity.kt`
+- **Added intent handling** for "OPEN_FRIEND_PROFILE" action in `onNewIntent` and `onResume`
+- **Implemented `openSpotifyProfile` method** to launch friend profiles via:
+ - Spotify app URI (`spotify:user:{userId}`)
+ - Web fallback (`https://open.spotify.com/user/{userId}`)
+- **Added method channel support** for `openFriendProfile` calls from Flutter
+
+### 2. iOS Widget Improvements
+
+#### `PlaytivityWidget.swift`
+- **Wrapped activity items in `Link` components** to enable tapping
+- **Added fallback for invalid URLs** to maintain non-clickable display
+- **Used `spotify:user:{userId}` URLs** for direct Spotify profile access
+
+#### `WidgetDataProvider.swift`
+- **Added `userId` field** to `FriendActivity` struct
+- **Updated data loading** to read user IDs from UserDefaults
+- **Updated preview data** with sample user IDs
+
+### 3. Flutter Integration
+
+#### `friend_profile_launcher.dart` (New File)
+- **Created unified profile launcher** for both widget and app usage
+- **Dual approach**: Native Android method + direct URL launcher fallback
+- **Comprehensive error handling** and logging
+- **Cross-platform support** for opening Spotify profiles
+
+#### `activity_card.dart`
+- **Updated existing clickable elements** to use new `FriendProfileLauncher`
+- **Maintained consistency** between app and widget behavior
+- **Enhanced user experience** with unified profile opening
+
+#### `widget_service.dart`
+- **Already saving user IDs** - no changes needed
+- **Existing data structure** supports the new functionality
+
+## How It Works
+
+### Widget Click Flow (Android)
+1. User taps on activity item in widget
+2. Fill-in intent contains `friendUserId` and `friendName`
+3. MainActivity receives intent with action "OPEN_FRIEND_PROFILE"
+4. `openSpotifyProfile` method tries Spotify app first, then web fallback
+5. Friend's profile opens in Spotify or browser
+
+### Widget Click Flow (iOS)
+1. User taps on activity item in widget
+2. `Link` component with `spotify:user:{userId}` URL is activated
+3. iOS handles the URL scheme routing
+4. Spotify app or web browser opens friend's profile
+
+### App Click Flow (Flutter)
+1. User taps friend name/avatar in activity card
+2. `FriendProfileLauncher.openFriendProfile` is called
+3. Method tries native Android channel first, then direct URL launch
+4. Friend's profile opens via best available method
+
+## Benefits
+
+1. **Seamless Integration**: Widget now provides same clickable functionality as main app
+2. **Cross-Platform**: Works on both Android and iOS widgets
+3. **Robust Fallbacks**: Multiple fallback methods ensure profiles always open
+4. **Consistent UX**: Same behavior across widget and app
+5. **Performance**: Efficient click handling without rebuilding widget
+6. **User Engagement**: Quick access to friend profiles increases app utility
+
+## Technical Details
+
+### Data Flow
+```
+Flutter Activity Data โ Widget Service โ SharedPreferences โ Native Widget โ Click Handler โ Spotify Profile
+```
+
+### Error Handling
+- Invalid user IDs are handled gracefully
+- Missing Spotify app falls back to web browser
+- Network issues are logged but don't crash the widget
+- Malformed URLs show non-clickable fallback on iOS
+
+### Testing
+- Clickable functionality works with existing widget data
+- No additional Flutter changes needed for basic functionality
+- Compatible with existing widget update mechanisms
+- User IDs are already being saved and retrieved correctly
+
+## Future Enhancements
+
+1. **Track/Content Clicking**: Make track/playlist content clickable in widget
+2. **Deep Linking**: Add custom app deep links for friend profiles
+3. **Analytics**: Track widget click engagement
+4. **Customization**: Allow users to configure click behavior
+5. **Visual Feedback**: Add press states to widget items
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 0816e6d..03886a3 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,7 +1,6 @@
plugins {
id("com.android.application")
id("kotlin-android")
- id("org.jetbrains.kotlin.plugin.compose")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@@ -11,20 +10,6 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_21.toString()
- }
-
- buildFeatures {
- compose = true
- viewBinding = true
- }
-
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.mliem.playtivity"
@@ -35,8 +20,28 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
}
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_21.toString()
+ freeCompilerArgs += listOf("-Xlint:-options")
+ }
+
+ buildFeatures {
+ buildConfig = true
+ viewBinding = true
+ }
signingConfigs {
+ getByName("debug") {
+ // Default debug config
+ }
+
create("release") {
storeFile = file(System.getenv("ANDROID_KEYSTORE_PATH") ?: "release-key.jks")
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
@@ -44,9 +49,9 @@ android {
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
-
+
buildTypes {
- release {
+ getByName("release") {
signingConfig = if (System.getenv("ANDROID_KEYSTORE_PATH") != null) {
signingConfigs.getByName("release")
} else {
@@ -55,6 +60,14 @@ android {
}
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ // Add additional optimization flags
+ isDebuggable = false
+ isShrinkResources = true
+ }
+
+ getByName("debug") {
+ isDebuggable = true
+ applicationIdSuffix = ".debug"
}
}
}
@@ -72,4 +85,6 @@ dependencies {
implementation("androidx.glance:glance-material3:1.1.1")
// For coroutines support
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+ // For Java 8+ APIs on older Android versions
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 721dce0..b7d5c1f 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -40,3 +40,23 @@
# Keep model classes for serialization
-keep class com.mliem.playtivity.models.** { *; }
+
+# Keep widget classes and methods
+-keep class com.mliem.playtivity.widget.** { *; }
+-keep class es.antonborri.home_widget.** { *; }
+
+# Keep widget receiver and service
+-keep class * extends android.appwidget.AppWidgetProvider
+-keep class * extends android.app.Service
+
+# Keep SharedPreferences methods
+-keep class android.content.SharedPreferences { *; }
+-keep class android.content.SharedPreferences$Editor { *; }
+
+# Keep method channel classes
+-keep class io.flutter.plugin.common.MethodChannel { *; }
+-keep class io.flutter.plugin.common.MethodChannel$MethodCallHandler { *; }
+
+# Keep Glance widget classes
+-keep class androidx.glance.** { *; }
+-dontwarn androidx.glance.**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c6205fb..9942d01 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
-
-
-
+ android:label="Playtivity"
+ android:usesCleartextTraffic="true"
+ android:networkSecurityConfig="@xml/network_security_config">
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt
index 74bbd56..6567640 100644
--- a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt
@@ -3,64 +3,297 @@ package com.mliem.playtivity
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.mliem.playtivity.widget.PlaytivityWidgetReceiver
-import com.mliem.playtivity.widget.PlaytivityAppWidget
+import com.mliem.playtivity.widget.PlaytivityWidgetProvider
import com.mliem.playtivity.widget.ImageCacheService
-import androidx.glance.appwidget.updateAll
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
+import android.app.PendingIntent
-class MainActivity : FlutterActivity() {
- private val CHANNEL = "playtivity_widget"
+class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playtivity_widget"
+ private val UPDATE_CHANNEL = "com.mliem.playtivity/update_launcher"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
- MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
+ // Widget channel for home screen widget updates
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WIDGET_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"updateWidget" -> {
updateWidget()
result.success("Widget update triggered")
}
+ "cacheImages" -> {
+ ImageCacheService.startImageCaching(this)
+ result.success("Image caching started")
+ }
+ "openFriendProfile" -> {
+ val userId = call.argument("userId")
+ val friendName = call.argument("friendName")
+ if (userId != null) {
+ openSpotifyProfile(userId, friendName)
+ result.success("Profile opened")
+ } else {
+ result.error("INVALID_ARGUMENTS", "User ID is required", null)
+ }
+ }
+ else -> {
+ result.notImplemented()
+ }
+ }
+ }
+
+ // Update channel for APK installation
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, UPDATE_CHANNEL).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "installApk" -> {
+ val filePath = call.argument("filePath")
+ if (filePath != null) {
+ installApk(filePath, result)
+ } else {
+ result.error("INVALID_ARGUMENTS", "File path is required", null)
+ }
+ }
+ "installApkDirect" -> {
+ val filePath = call.argument("filePath")
+ if (filePath != null) {
+ installApkDirect(filePath, result)
+ } else {
+ result.error("INVALID_ARGUMENTS", "File path is required", null)
+ }
+ }
+ "canInstallPackages" -> {
+ result.success(canInstallPackages())
+ }
+ "requestInstallPermission" -> {
+ requestInstallPermission(result)
+ }
else -> {
result.notImplemented()
}
}
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleWidgetIntent(intent)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ handleWidgetIntent(intent)
+ }
+
+ private fun handleWidgetIntent(intent: Intent?) {
+ android.util.Log.d("PlaytivityWidget", "handleWidgetIntent called with intent: $intent")
+ intent?.let {
+ android.util.Log.d("PlaytivityWidget", "Intent action: ${it.action}")
+ android.util.Log.d("PlaytivityWidget", "Intent extras: ${it.extras}")
+
+ when (it.action) {
+ "OPEN_FRIEND_PROFILE" -> {
+ val friendUserId = it.getStringExtra("friendUserId")
+ val friendName = it.getStringExtra("friendName")
+
+ android.util.Log.d("PlaytivityWidget", "Opening friend profile: $friendName (ID: $friendUserId)")
+
+ if (!friendUserId.isNullOrEmpty()) {
+ // Try to open Spotify profile
+ openSpotifyProfile(friendUserId, friendName)
+ } else {
+ android.util.Log.w("PlaytivityWidget", "Friend user ID is null or empty!")
+ }
+ }
+ "REFRESH_WIDGET" -> {
+ android.util.Log.d("PlaytivityWidget", "Refresh widget requested")
+ updateWidget()
+ }
+ else -> {
+ android.util.Log.d("PlaytivityWidget", "Unknown action: ${it.action}")
+ }
+ }
+ }
+ }
+
+ private fun openSpotifyProfile(userId: String, friendName: String?) {
+ try {
+ // Create Spotify user URI
+ val spotifyUri = "spotify:user:$userId"
+ val spotifyIntent = Intent(Intent.ACTION_VIEW, Uri.parse(spotifyUri))
+
+ android.util.Log.d("PlaytivityWidget", "Attempting to open Spotify profile: $spotifyUri")
+
+ // Try to open in Spotify app first
+ spotifyIntent.setPackage("com.spotify.music")
+ if (spotifyIntent.resolveActivity(packageManager) != null) {
+ startActivity(spotifyIntent)
+ android.util.Log.d("PlaytivityWidget", "Opened in Spotify app")
+ return
+ }
+
+ // Fallback to web browser
+ val webUrl = "https://open.spotify.com/user/$userId"
+ val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(webUrl))
+
+ if (webIntent.resolveActivity(packageManager) != null) {
+ startActivity(webIntent)
+ android.util.Log.d("PlaytivityWidget", "Opened in web browser: $webUrl")
+ } else {
+ android.util.Log.w("PlaytivityWidget", "No app found to open Spotify profile")
+ }
+
+ } catch (e: Exception) {
+ android.util.Log.e("PlaytivityWidget", "Error opening Spotify profile for $userId", e)
+ }
+ }
private fun updateWidget() {
try {
- // Use direct Glance updateAll approach
- CoroutineScope(Dispatchers.Main).launch {
- // Add a small delay to ensure SharedPreferences data is committed
- delay(100)
-
- // Log both SharedPreferences to see where data is being saved
- val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
- val homeWidgetPrefs = getSharedPreferences("HomeWidgetPreferences", MODE_PRIVATE)
-
- val flutterActivitiesCount = flutterPrefs.getString("flutter.activities_count", "0")
- val homeWidgetActivitiesCount = homeWidgetPrefs.getString("activities_count", "0")
-
- android.util.Log.d("PlaytivityWidget", "Before update - FlutterSharedPreferences activities_count: $flutterActivitiesCount")
- android.util.Log.d("PlaytivityWidget", "Before update - HomeWidgetPreferences activities_count: $homeWidgetActivitiesCount")
-
- // Start image caching service first
- ImageCacheService.startImageCaching(this@MainActivity)
- android.util.Log.d("PlaytivityWidget", "Image caching service started")
-
- // Update the widget using direct Glance updateAll
- PlaytivityAppWidget().updateAll(this@MainActivity)
- android.util.Log.d("PlaytivityWidget", "Direct Glance widget update triggered")
+ // Check both SharedPreferences sources
+ val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
+ val homeWidgetPrefs = getSharedPreferences("HomeWidgetPreferences", MODE_PRIVATE)
+ val flutterActivitiesCount = flutterPrefs.getString("flutter.activities_count", "0")
+ val homeWidgetActivitiesCount = homeWidgetPrefs.getString("activities_count", "0")
+
+ // Start image caching service first
+ ImageCacheService.startImageCaching(this)
+
+ // Update all widgets using traditional AppWidgetManager
+ val appWidgetManager = AppWidgetManager.getInstance(this)
+ val componentName = ComponentName(this, PlaytivityWidgetReceiver::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
+
+ for (appWidgetId in appWidgetIds) {
+ PlaytivityWidgetProvider.updateAppWidget(this, appWidgetManager, appWidgetId)
}
} catch (e: Exception) {
android.util.Log.e("PlaytivityWidget", "Error updating widget", e)
}
}
+
+ // Check if the app can install packages
+ private fun canInstallPackages(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ packageManager.canRequestPackageInstalls()
+ } else {
+ true // On older versions, this permission is granted by default
+ }
+ }
+
+ // Request install permission for Android 8.0+
+ private fun requestInstallPermission(result: MethodChannel.Result) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (!packageManager.canRequestPackageInstalls()) {
+ android.util.Log.d("Playtivity", "Requesting install packages permission")
+ val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
+ data = Uri.parse("package:$packageName")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ try {
+ startActivity(intent)
+ result.success("PERMISSION_REQUESTED")
+ } catch (e: Exception) {
+ android.util.Log.e("Playtivity", "Error requesting install permission: ${e.message}", e)
+ result.error("PERMISSION_REQUEST_FAILED", "Failed to open permission settings", e.toString())
+ }
+ } else {
+ result.success("PERMISSION_ALREADY_GRANTED")
+ }
+ } else {
+ result.success("PERMISSION_NOT_REQUIRED")
+ }
+ }
+
+ // Handle APK installation using FileProvider
+ private fun installApk(filePath: String, result: MethodChannel.Result) {
+ try {
+ // Check if we have permission to install packages on Android 8.0+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
+ android.util.Log.e("Playtivity", "No permission to install packages. User needs to enable 'Unknown sources' for this app.")
+ result.error("PERMISSION_DENIED", "Permission to install packages is required. Please enable 'Unknown sources' for Playtivity in your device settings.", null)
+ return
+ }
+
+ val file = java.io.File(filePath)
+ if (!file.exists()) {
+ android.util.Log.e("Playtivity", "APK file not found at $filePath")
+ result.error("FILE_NOT_FOUND", "APK file not found at $filePath", null)
+ return
+ }
+
+ android.util.Log.d("Playtivity", "Installing APK from $filePath (size: ${file.length()} bytes)")
+
+ // Create content URI using FileProvider
+ val contentUri = androidx.core.content.FileProvider.getUriForFile(
+ this,
+ "${packageName}.fileprovider",
+ file
+ )
+
+ android.util.Log.d("Playtivity", "FileProvider URI: $contentUri")
+
+ // Create intent to install the APK
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(contentUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+
+ // Check if there's an app that can handle this intent
+ val packageManager = packageManager
+ val activities = packageManager.queryIntentActivities(intent, 0)
+
+ if (activities.isEmpty()) {
+ android.util.Log.e("Playtivity", "No app found to handle APK installation")
+ result.error("NO_HANDLER", "No app found to handle APK installation", null)
+ return
+ }
+
+ android.util.Log.d("Playtivity", "Found ${activities.size} apps that can handle APK installation")
+
+ // Start the installation activity
+ startActivity(intent)
+ android.util.Log.d("Playtivity", "APK installation intent started successfully")
+ result.success(true)
+ } catch (e: Exception) {
+ android.util.Log.e("Playtivity", "Error installing APK: ${e.message}", e)
+ result.error("INSTALLATION_ERROR", "Error installing APK: ${e.message}", e.toString())
+ }
+ }
+
+ // Alternative installation method using standard Intent
+ private fun installApkDirect(filePath: String, result: MethodChannel.Result) {
+ try {
+ val file = java.io.File(filePath)
+ if (!file.exists()) {
+ android.util.Log.e("Playtivity", "APK file not found at $filePath")
+ result.error("FILE_NOT_FOUND", "APK file not found at $filePath", null)
+ return
+ }
+
+ android.util.Log.d("Playtivity", "Installing APK directly from $filePath")
+
+ // Create file URI directly
+ val fileUri = android.net.Uri.fromFile(file)
+
+ // Create intent to install the APK
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(fileUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ // Start the installation activity
+ startActivity(intent)
+ android.util.Log.d("Playtivity", "Direct APK installation intent started successfully")
+ result.success(true)
+ } catch (e: Exception) {
+ android.util.Log.e("Playtivity", "Error installing APK directly: ${e.message}", e)
+ result.error("INSTALLATION_ERROR", "Error installing APK directly: ${e.message}", e.toString())
+ }
+ }
}
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageCacheService.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageCacheService.kt
index ae84373..6476c54 100644
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageCacheService.kt
+++ b/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageCacheService.kt
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
-import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.runBlocking
class ImageCacheService : IntentService("ImageCacheService") {
@@ -27,39 +26,54 @@ class ImageCacheService : IntentService("ImageCacheService") {
}
}
- private fun cacheAllFriendImages() {
+ private fun cacheAllFriendImages() {
try {
- android.util.Log.d("ImageCacheService", "Starting to cache friend images")
+ android.util.Log.d("ImageCacheService", "Starting image cache update")
val prefs = getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE)
val activitiesCount = prefs.getString("activities_count", "0")?.toIntOrNull() ?: 0
runBlocking {
- // Cache images for all friends (no longer limited to 5)
+ var cachedCount = 0
+ var failedCount = 0
+
for (i in 0 until activitiesCount) {
val friendImage = prefs.getString("friend_${i}_image", "") ?: ""
val friendName = prefs.getString("friend_${i}_name", "") ?: ""
if (friendImage.isNotEmpty() && friendName.isNotEmpty()) {
- android.util.Log.d("ImageCacheService", "Caching image for friend $i: $friendName")
val cachedPath = ImageDownloader.downloadAndCacheImage(this@ImageCacheService, friendImage, i)
if (cachedPath != null) {
- // Save the cached path to preferences for the widget to use
+ // Save the cached path to both preference sources for the widget to use
prefs.edit()
.putString("friend_${i}_cached_image", cachedPath)
.apply()
- android.util.Log.d("ImageCacheService", "Cached image path saved: $cachedPath")
+
+ // Also save to Flutter preferences as backup
+ val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
+ flutterPrefs.edit()
+ .putString("flutter.friend_${i}_cached_image", cachedPath)
+ .apply()
+
+ cachedCount++
+ } else {
+ failedCount++
}
}
}
+
+
}
- // Update the widget after caching images
- runBlocking {
- PlaytivityAppWidget().updateAll(this@ImageCacheService)
+ // Update all widgets after caching images
+ val appWidgetManager = AppWidgetManager.getInstance(this)
+ val componentName = ComponentName(this, PlaytivityWidgetReceiver::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
+
+ for (appWidgetId in appWidgetIds) {
+ PlaytivityWidgetProvider.updateAppWidget(this, appWidgetManager, appWidgetId)
}
- android.util.Log.d("ImageCacheService", "Image caching completed, widget updated")
} catch (e: Exception) {
android.util.Log.e("ImageCacheService", "Error caching images", e)
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageDownloader.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageDownloader.kt
index e361c34..2095536 100644
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageDownloader.kt
+++ b/android/app/src/main/kotlin/com/mliem/playtivity/widget/ImageDownloader.kt
@@ -69,12 +69,9 @@ object ImageDownloader {
// If already cached, return the path
if (file.exists()) {
- android.util.Log.d("ImageDownloader", "Using cached image for friend $friendIndex")
return@withContext file.absolutePath
}
- android.util.Log.d("ImageDownloader", "Downloading image for friend $friendIndex: $imageUrl")
-
// Download the image
val url = URL(imageUrl)
val connection = url.openConnection()
@@ -100,13 +97,12 @@ object ImageDownloader {
circularBitmap.recycle()
resizedBitmap.recycle()
- android.util.Log.d("ImageDownloader", "Successfully cached image for friend $friendIndex")
return@withContext file.absolutePath
}
null
} catch (e: Exception) {
- android.util.Log.e("ImageDownloader", "Failed to download image for friend $friendIndex", e)
+ android.util.Log.e("ImageDownloader", "Failed to download/cache image", e)
null
}
}
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/LaunchSpotifyProfileCallback.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/LaunchSpotifyProfileCallback.kt
deleted file mode 100644
index 007c6c5..0000000
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/LaunchSpotifyProfileCallback.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.mliem.playtivity.widget
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.glance.GlanceId
-import androidx.glance.action.ActionParameters
-import androidx.glance.appwidget.action.ActionCallback
-
-/**
- * Action callback to launch Spotify to a friend's profile
- */
-class LaunchSpotifyProfileCallback : ActionCallback {
- override suspend fun onAction(
- context: Context,
- glanceId: GlanceId,
- parameters: ActionParameters
- ) {
- val userId = parameters[userIdKey] ?: ""
-
- if (userId.isNotEmpty()) {
- try {
- // Try to launch Spotify app with user profile URI
- val spotifyUri = "spotify:user:$userId"
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(spotifyUri)).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- setPackage("com.spotify.music") // Prefer Spotify app
- }
-
- // Check if Spotify app is available
- if (intent.resolveActivity(context.packageManager) != null) {
- context.startActivity(intent)
- android.util.Log.d("LaunchSpotifyProfile", "Launched Spotify app for user: $userId")
- } else {
- // Fallback to web browser
- val webUrl = "https://open.spotify.com/user/$userId"
- val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(webUrl)).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(webIntent)
- android.util.Log.d("LaunchSpotifyProfile", "Launched Spotify web for user: $userId")
- }
- } catch (e: Exception) {
- android.util.Log.e("LaunchSpotifyProfile", "Error launching Spotify profile", e)
- }
- } else {
- android.util.Log.w("LaunchSpotifyProfile", "No user ID provided")
- }
- }
-
- companion object {
- val userIdKey = ActionParameters.Key("userId")
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetProvider.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetProvider.kt
index 73641c1..2f6e527 100644
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetProvider.kt
+++ b/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetProvider.kt
@@ -1,413 +1,370 @@
package com.mliem.playtivity.widget
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
import android.content.Context
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.glance.GlanceId
-import androidx.glance.GlanceModifier
-import androidx.glance.GlanceTheme
-import androidx.glance.Image
-import androidx.glance.ImageProvider
-import androidx.glance.action.ActionParameters
-import androidx.glance.action.actionParametersOf
-import androidx.glance.action.actionStartActivity
-import androidx.glance.action.clickable
-import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.action.actionRunCallback
-import androidx.glance.appwidget.components.Scaffold
-import androidx.glance.appwidget.components.TitleBar
-import androidx.glance.appwidget.cornerRadius
-import androidx.glance.appwidget.provideContent
-import androidx.glance.background
-import androidx.glance.layout.Alignment
-import androidx.glance.layout.Box
-import androidx.glance.layout.Column
-import androidx.glance.appwidget.lazy.LazyColumn
-import androidx.glance.appwidget.lazy.items
-import androidx.glance.layout.Row
-import androidx.glance.layout.Spacer
-import androidx.glance.layout.fillMaxSize
-import androidx.glance.layout.fillMaxWidth
-import androidx.glance.layout.height
-import androidx.glance.layout.padding
-import androidx.glance.layout.size
-import androidx.glance.layout.width
-import androidx.glance.text.FontWeight
-import androidx.glance.text.Text
-import androidx.glance.text.TextStyle
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.view.View
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import android.os.Bundle
import com.mliem.playtivity.MainActivity
import com.mliem.playtivity.R
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.abs
-data class FriendActivity(
+data class ActivityItem(
val friendName: String,
val trackName: String,
val artistName: String,
- val friendImageUrl: String,
- val cachedImagePath: String = "",
- val friendUserId: String = "",
- val timestamp: Long = 0L,
- val isCurrentlyPlaying: Boolean = false,
- val activityType: String = "track"
+ val cachedImagePath: String,
+ val timestamp: Long,
+ val isCurrentlyPlaying: Boolean,
+ val activityType: String,
+ val userId: String = ""
) {
- fun getStatusText(): String {
- val currentTime = System.currentTimeMillis()
- val timestampDate = if (timestamp > 0) timestamp else currentTime
- val timeDiffMinutes = (currentTime - timestampDate) / (1000 * 60)
+ fun getTimeAgoText(): String {
+ if (isCurrentlyPlaying) {
+ return "Listening now"
+ }
- // Consider recent if within 1 minute (like Flutter app)
- val isRecent = timeDiffMinutes < 1
+ val currentTime = System.currentTimeMillis()
+ val diffMinutes = (currentTime - timestamp) / (1000 * 60)
return when {
- isCurrentlyPlaying || isRecent -> {
- if (activityType == "playlist") {
- "Listening to playlist now"
- } else {
- "Listening now"
- }
- }
+ diffMinutes < 1 -> "Just now"
+ diffMinutes < 60 -> "${diffMinutes}m ago"
+ diffMinutes < 1440 -> "${diffMinutes / 60}h ago"
+ diffMinutes < 10080 -> "${diffMinutes / 1440}d ago"
else -> {
- if (activityType == "playlist") {
- "Played playlist ${formatTimeAgo(timeDiffMinutes)}"
- } else {
- "Played ${formatTimeAgo(timeDiffMinutes)}"
- }
+ val date = Date(timestamp)
+ SimpleDateFormat("MMM dd", Locale.getDefault()).format(date)
}
}
}
-
- fun isRecentOrPlaying(): Boolean {
- val currentTime = System.currentTimeMillis()
- val timestampDate = if (timestamp > 0) timestamp else currentTime
- val timeDiffMinutes = (currentTime - timestampDate) / (1000 * 60)
- return isCurrentlyPlaying || timeDiffMinutes < 1
- }
-
- private fun formatTimeAgo(minutes: Long): String {
- return when {
- minutes < 1 -> "just now"
- minutes < 60 -> "${minutes}m ago"
- minutes < 1440 -> "${minutes / 60}h ago"
- else -> "${minutes / 1440}d ago"
- }
- }
}
-class PlaytivityAppWidget : GlanceAppWidget() {
+class PlaytivityWidgetProvider : AppWidgetProvider() {
- override suspend fun provideGlance(context: Context, id: GlanceId) {
- provideContent {
- GlanceTheme {
- PlaytivityContent(context)
- }
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
- @Composable
- private fun PlaytivityContent(context: Context) {
- // Read widget data from home_widget SharedPreferences (without flutter. prefix)
- val prefs = context.getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE)
-
- // Force a fresh read by logging timestamp
- val currentTime = System.currentTimeMillis()
- val lastUpdate = prefs.getString("last_update", "never") ?: "never"
-
- val activitiesCount = prefs.getString("activities_count", "0")?.toIntOrNull() ?: 0
-
- // Debug: Log what data we're reading with timestamp
- android.util.Log.d("PlaytivityWidget", "Reading widget data at $currentTime:")
- android.util.Log.d("PlaytivityWidget", " last_update: $lastUpdate")
- android.util.Log.d("PlaytivityWidget", " activities_count: $activitiesCount")
-
- // Log all preferences keys for debugging
- val allPrefs = prefs.all
- android.util.Log.d("PlaytivityWidget", "All HomeWidget preferences keys: ${allPrefs.keys}")
-
- // Enhanced debugging: Check if we have data for more friends than activitiesCount suggests
- for (i in 0 until activitiesCount) {
- val friendName = prefs.getString("friend_${i}_name", "") ?: ""
- val friendTrack = prefs.getString("friend_${i}_track", "") ?: ""
- val friendArtist = prefs.getString("friend_${i}_artist", "") ?: ""
- val friendImage = prefs.getString("friend_${i}_image", "") ?: ""
- android.util.Log.d("PlaytivityWidget", " friend_${i}: $friendName - $friendTrack by $friendArtist (image: $friendImage)")
- }
-
- // Additional debugging: Check if there are more friends beyond the activitiesCount
- android.util.Log.d("PlaytivityWidget", "Checking for additional friends beyond activitiesCount...")
- var foundAdditionalFriends = 0
- for (i in activitiesCount until (activitiesCount + 10)) {
- val friendName = prefs.getString("friend_${i}_name", "") ?: ""
- val friendTrack = prefs.getString("friend_${i}_track", "") ?: ""
- if (friendName.isNotEmpty() && friendTrack.isNotEmpty()) {
- foundAdditionalFriends++
- android.util.Log.d("PlaytivityWidget", " EXTRA friend_${i}: $friendName - $friendTrack")
+ companion object {
+ fun updateAppWidget(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int
+ ) {
+ // Create RemoteViews
+ val views = RemoteViews(context.packageName, R.layout.widget_layout)
+
+ // Read widget data from SharedPreferences
+ val prefs = context.getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE)
+ val flutterPrefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
+
+ val activitiesCount = try {
+ prefs.getString("activities_count", null)?.toIntOrNull()
+ ?: flutterPrefs.getString("flutter.activities_count", "0")?.toIntOrNull() ?: 0
+ } catch (e: Exception) {
+ android.util.Log.w("PlaytivityWidget", "Error parsing activities_count", e)
+ 0
}
- }
- android.util.Log.d("PlaytivityWidget", "Found $foundAdditionalFriends additional friends beyond activitiesCount")
-
- Scaffold(
- titleBar = {
- Box(
- modifier = GlanceModifier
- .fillMaxWidth()
- .padding(16.dp)
- .clickable(actionStartActivity())
- ) {
- Row(
- modifier = GlanceModifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_music_note),
- contentDescription = "App icon",
- modifier = GlanceModifier.size(24.dp)
- )
- Spacer(modifier = GlanceModifier.width(8.dp))
- Text(
- text = "Friends' Activities",
- style = TextStyle(
- color = GlanceTheme.colors.onSurface,
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold
+
+ if (activitiesCount > 0) {
+ // Parse all activities
+ val activities = mutableListOf()
+
+ for (i in 0 until activitiesCount) {
+ val friendName = prefs.getString("friend_${i}_name", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_name", "") ?: ""
+ val trackName = prefs.getString("friend_${i}_track", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_track", "") ?: ""
+ val artistName = prefs.getString("friend_${i}_artist", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_artist", "") ?: ""
+ val cachedImagePath = prefs.getString("friend_${i}_cached_image", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_cached_image", "") ?: ""
+ val timestampString = prefs.getString("friend_${i}_timestamp", "0")
+ ?: flutterPrefs.getString("flutter.friend_${i}_timestamp", "0") ?: "0"
+ val isCurrentlyPlayingString = prefs.getString("friend_${i}_is_currently_playing", "false")
+ ?: flutterPrefs.getString("flutter.friend_${i}_is_currently_playing", "false") ?: "false"
+ val activityType = prefs.getString("friend_${i}_activity_type", "track")
+ ?: flutterPrefs.getString("flutter.friend_${i}_activity_type", "track") ?: "track"
+ val userId = prefs.getString("friend_${i}_user_id", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_user_id", "") ?: ""
+
+ val timestamp = try {
+ timestampString.toLongOrNull() ?: System.currentTimeMillis()
+ } catch (e: Exception) {
+ System.currentTimeMillis()
+ }
+
+ val isCurrentlyPlaying = try {
+ isCurrentlyPlayingString.toBoolean()
+ } catch (e: Exception) {
+ false
+ }
+
+ if (friendName.isNotEmpty() && trackName.isNotEmpty()) {
+ activities.add(
+ ActivityItem(
+ friendName = friendName,
+ trackName = trackName,
+ artistName = artistName,
+ cachedImagePath = cachedImagePath,
+ timestamp = timestamp,
+ isCurrentlyPlaying = isCurrentlyPlaying,
+ activityType = activityType,
+ userId = userId
)
)
}
- // Position refresh button on the right using Box alignment
- Box(
- modifier = GlanceModifier.fillMaxWidth(),
- contentAlignment = Alignment.CenterEnd
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_refresh),
- contentDescription = "Refresh",
- modifier = GlanceModifier
- .size(24.dp)
- .clickable(actionRunCallback())
- )
+ }
+
+ if (activities.isNotEmpty()) {
+ // Sort activities by timestamp (most recent first)
+ val sortedActivities = activities.sortedWith { a, b ->
+ when {
+ a.isCurrentlyPlaying && !b.isCurrentlyPlaying -> -1
+ !a.isCurrentlyPlaying && b.isCurrentlyPlaying -> 1
+ else -> b.timestamp.compareTo(a.timestamp)
+ }
}
+
+ // Set up ListView with adapter
+ val intent = Intent(context, PlaytivityWidgetService::class.java)
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ views.setRemoteAdapter(R.id.activities_list, intent)
+
+ // Set up click template for individual items
+ val clickTemplate = Intent(context, MainActivity::class.java)
+ clickTemplate.action = "OPEN_FRIEND_PROFILE"
+ clickTemplate.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ val clickPendingTemplate = PendingIntent.getActivity(
+ context, 0, clickTemplate,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ views.setPendingIntentTemplate(R.id.activities_list, clickPendingTemplate)
+
+ // Set empty view
+ views.setEmptyView(R.id.activities_list, R.id.empty_state)
+
+ // Show activities list
+ views.setViewVisibility(R.id.activities_list, View.VISIBLE)
+ views.setViewVisibility(R.id.empty_state, View.GONE)
+
+ // Update currently playing count
+ val currentlyPlayingCount = sortedActivities.count { it.isCurrentlyPlaying }
+ } else {
+ showEmptyState(views)
}
- },
- backgroundColor = GlanceTheme.colors.widgetBackground,
- modifier = GlanceModifier
- .fillMaxSize()
- ) {
- if (activitiesCount > 0) {
- ActivitiesView(prefs, activitiesCount)
} else {
- NoActivitiesView()
+ showEmptyState(views)
}
+
+ // Set click intent to open main app only for header area
+ val mainIntent = Intent(context, MainActivity::class.java)
+ val mainPendingIntent = PendingIntent.getActivity(
+ context, 0, mainIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_title, mainPendingIntent)
+
+ // Set refresh button click intent
+ val refreshIntent = Intent(context, MainActivity::class.java)
+ refreshIntent.action = "REFRESH_WIDGET"
+ refreshIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ val refreshPendingIntent = PendingIntent.getActivity(
+ context, 1, refreshIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.refresh_button, refreshPendingIntent)
+
+ // Update the widget
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+
+ private fun showEmptyState(views: RemoteViews) {
+ views.setViewVisibility(R.id.activities_list, View.GONE)
+ views.setViewVisibility(R.id.empty_state, View.VISIBLE)
}
}
+}
- @Composable
- private fun NoActivitiesView() {
- Box(
- modifier = GlanceModifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_person),
- contentDescription = "No activities",
- modifier = GlanceModifier.size(32.dp)
- )
- Spacer(modifier = GlanceModifier.height(8.dp))
- Text(
- text = "No recent activities",
- style = TextStyle(
- color = GlanceTheme.colors.onSurface,
- fontSize = 12.sp,
- fontWeight = FontWeight.Bold
- )
- )
- Text(
- text = "Friends haven't been listening recently",
- style = TextStyle(
- color = GlanceTheme.colors.onSurfaceVariant,
- fontSize = 10.sp
- )
- )
- }
- }
+class PlaytivityWidgetService : RemoteViewsService() {
+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
+ return PlaytivityWidgetItemFactory(applicationContext, intent)
}
+}
- @Composable
- private fun ActivitiesView(prefs: android.content.SharedPreferences, activitiesCount: Int) {
- android.util.Log.d("PlaytivityWidget", "ActivitiesView called with activitiesCount: $activitiesCount")
+class PlaytivityWidgetItemFactory(
+ private val context: Context,
+ private val intent: Intent
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private var activities = mutableListOf()
+ private val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+
+ override fun onCreate() {
+ // Nothing to do
+ }
+
+ override fun onDataSetChanged() {
+ loadActivities()
+ }
+
+ override fun onDestroy() {
+ activities.clear()
+ }
+
+ override fun getCount(): Int = activities.size
+
+ override fun getViewAt(position: Int): RemoteViews {
+ if (position >= activities.size) {
+ return RemoteViews(context.packageName, R.layout.widget_activity_item)
+ }
- // Create a list of all valid activities
- val activities = (0 until activitiesCount).mapNotNull { index ->
- val friendName = prefs.getString("friend_${index}_name", "") ?: ""
- val friendTrack = prefs.getString("friend_${index}_track", "") ?: ""
- val friendArtist = prefs.getString("friend_${index}_artist", "") ?: ""
- val friendImage = prefs.getString("friend_${index}_image", "") ?: ""
- val cachedImagePath = prefs.getString("friend_${index}_cached_image", "") ?: ""
- val friendUserId = prefs.getString("friend_${index}_user_id", "") ?: ""
- val timestampString = prefs.getString("friend_${index}_timestamp", "0") ?: "0"
- val isCurrentlyPlayingString = prefs.getString("friend_${index}_is_currently_playing", "false") ?: "false"
- val activityType = prefs.getString("friend_${index}_activity_type", "track") ?: "track"
-
- val timestamp = timestampString.toLongOrNull() ?: 0L
- val isCurrentlyPlaying = isCurrentlyPlayingString.toBoolean()
-
- val isValid = friendName.isNotEmpty() && friendTrack.isNotEmpty()
- android.util.Log.d("PlaytivityWidget", "Activity $index: name='$friendName', track='$friendTrack', valid=$isValid, timestamp=$timestamp, playing=$isCurrentlyPlaying, type=$activityType")
-
- if (isValid) {
- FriendActivity(
- friendName = friendName,
- trackName = friendTrack,
- artistName = friendArtist,
- friendImageUrl = friendImage,
- cachedImagePath = cachedImagePath,
- friendUserId = friendUserId,
- timestamp = timestamp,
- isCurrentlyPlaying = isCurrentlyPlaying,
- activityType = activityType
- )
- } else null
+ val activity = activities[position]
+ val views = RemoteViews(context.packageName, R.layout.widget_activity_item)
+
+ // Set track name
+ views.setTextViewText(R.id.track_name, activity.trackName)
+
+ // Set friend and artist
+ views.setTextViewText(R.id.friend_artist, "${activity.friendName} โข ${activity.artistName}")
+
+ // Set timestamp
+ views.setTextViewText(R.id.timestamp, activity.getTimeAgoText())
+
+ // Set status indicator
+ if (activity.isCurrentlyPlaying) {
+ views.setViewVisibility(R.id.status_indicator, View.VISIBLE)
+ views.setTextViewText(R.id.status_indicator, "๐ต")
+ } else {
+ views.setViewVisibility(R.id.status_indicator, View.GONE)
}
- android.util.Log.d("PlaytivityWidget", "Created activities list with ${activities.size} valid activities from $activitiesCount total")
+ // Load friend image
+ loadFriendImage(views, activity.cachedImagePath)
+
+ // Set click intent to open friend profile only if we have a valid user ID
+ if (activity.userId.isNotEmpty()) {
+ val extras = Bundle()
+ extras.putString("userId", activity.userId)
+ extras.putString("friendName", activity.friendName)
+ val fillInIntent = Intent()
+ fillInIntent.putExtras(extras)
+ views.setOnClickFillInIntent(R.id.widget_activity_item, fillInIntent)
+ }
- if (activities.isEmpty()) {
- // Show a message when no activities are available
- Box(
- modifier = GlanceModifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = "No recent friend activities",
- style = TextStyle(
- color = GlanceTheme.colors.onSurfaceVariant,
- fontSize = 11.sp
+ return views
+ }
+
+ override fun getLoadingView(): RemoteViews? = null
+
+ override fun getViewTypeCount(): Int = 1
+
+ override fun getItemId(position: Int): Long = position.toLong()
+
+ override fun hasStableIds(): Boolean = true
+
+ private fun loadActivities() {
+ activities.clear()
+
+ val prefs = context.getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE)
+ val flutterPrefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
+
+ val activitiesCount = try {
+ prefs.getString("activities_count", null)?.toIntOrNull()
+ ?: flutterPrefs.getString("flutter.activities_count", "0")?.toIntOrNull() ?: 0
+ } catch (e: Exception) {
+ android.util.Log.w("PlaytivityWidget", "Error parsing activities_count", e)
+ 0
+ }
+
+ for (i in 0 until activitiesCount) {
+ val friendName = prefs.getString("friend_${i}_name", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_name", "") ?: ""
+ val trackName = prefs.getString("friend_${i}_track", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_track", "") ?: ""
+ val artistName = prefs.getString("friend_${i}_artist", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_artist", "") ?: ""
+ val cachedImagePath = prefs.getString("friend_${i}_cached_image", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_cached_image", "") ?: ""
+ val timestampString = prefs.getString("friend_${i}_timestamp", "0")
+ ?: flutterPrefs.getString("flutter.friend_${i}_timestamp", "0") ?: "0"
+ val isCurrentlyPlayingString = prefs.getString("friend_${i}_is_currently_playing", "false")
+ ?: flutterPrefs.getString("flutter.friend_${i}_is_currently_playing", "false") ?: "false"
+ val activityType = prefs.getString("friend_${i}_activity_type", "track")
+ ?: flutterPrefs.getString("flutter.friend_${i}_activity_type", "track") ?: "track"
+ val userId = prefs.getString("friend_${i}_user_id", "")
+ ?: flutterPrefs.getString("flutter.friend_${i}_user_id", "") ?: ""
+
+ val timestamp = try {
+ timestampString.toLongOrNull() ?: System.currentTimeMillis()
+ } catch (e: Exception) {
+ System.currentTimeMillis()
+ }
+
+ val isCurrentlyPlaying = try {
+ isCurrentlyPlayingString.toBoolean()
+ } catch (e: Exception) {
+ false
+ }
+
+ if (friendName.isNotEmpty() && trackName.isNotEmpty()) {
+ activities.add(
+ ActivityItem(
+ friendName = friendName,
+ trackName = trackName,
+ artistName = artistName,
+ cachedImagePath = cachedImagePath,
+ timestamp = timestamp,
+ isCurrentlyPlaying = isCurrentlyPlaying,
+ activityType = activityType,
+ userId = userId
)
)
}
- } else {
- // Use LazyColumn for scrolling functionality
- LazyColumn(
- modifier = GlanceModifier
- .fillMaxSize()
- .padding(vertical = 8.dp)
- ) {
- items(activities.size) { index ->
- val activity = activities[index]
- FriendActivityItem(
- activity = activity,
- onClick = if (activity.friendUserId.isNotEmpty()) {
- actionRunCallback(
- parameters = actionParametersOf(LaunchSpotifyProfileCallback.userIdKey to activity.friendUserId)
- )
- } else {
- actionStartActivity()
- }
- )
- // Only add spacer if not the last item
- if (index < activities.size - 1) {
- Spacer(modifier = GlanceModifier.height(6.dp))
- }
- }
+ }
+
+ // Sort activities by timestamp (most recent first)
+ activities.sortWith { a, b ->
+ when {
+ a.isCurrentlyPlaying && !b.isCurrentlyPlaying -> -1
+ !a.isCurrentlyPlaying && b.isCurrentlyPlaying -> 1
+ else -> b.timestamp.compareTo(a.timestamp)
}
}
}
-
- @Composable
- private fun FriendActivityItem(
- activity: FriendActivity,
- onClick: androidx.glance.action.Action
- ) {
- Box(
- modifier = GlanceModifier
- .fillMaxWidth()
- .padding(12.dp)
- .clickable(onClick)
- ) {
- Row(
- modifier = GlanceModifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Friend profile image or fallback avatar
- if (activity.cachedImagePath.isNotEmpty() && java.io.File(activity.cachedImagePath).exists()) {
- // Use the cached friend profile image
- Image(
- provider = ImageProvider(android.graphics.BitmapFactory.decodeFile(activity.cachedImagePath)),
- contentDescription = "Friend profile",
- modifier = GlanceModifier.size(32.dp)
- )
- } else {
- // Fallback to avatar with background circle
- Box(
- modifier = GlanceModifier
- .size(32.dp)
- .background(GlanceTheme.colors.primary)
- .cornerRadius(16.dp),
- contentAlignment = Alignment.Center
- ) {
- Image(
- provider = ImageProvider(R.drawable.ic_person),
- contentDescription = "Friend",
- modifier = GlanceModifier.size(18.dp)
- )
- }
- }
-
- Spacer(modifier = GlanceModifier.width(12.dp))
- Column(
- modifier = GlanceModifier.fillMaxWidth()
- ) {
- Text(
- text = activity.trackName,
- style = TextStyle(
- color = GlanceTheme.colors.onSurface,
- fontSize = 13.sp,
- fontWeight = FontWeight.Bold
- ),
- maxLines = 2
- )
- Spacer(modifier = GlanceModifier.height(2.dp))
- Text(
- text = "${activity.friendName} โข ${activity.artistName}",
- style = TextStyle(
- color = GlanceTheme.colors.onSurfaceVariant,
- fontSize = 11.sp
- ),
- maxLines = 1
- )
- Spacer(modifier = GlanceModifier.height(4.dp))
- // Add status row with icon and text
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Image(
- provider = ImageProvider(
- if (activity.isRecentOrPlaying()) {
- R.drawable.ic_play_circle
- } else {
- R.drawable.ic_history
- }
- ),
- contentDescription = "Status",
- modifier = GlanceModifier.size(12.dp)
- )
- Spacer(modifier = GlanceModifier.width(4.dp))
- Text(
- text = activity.getStatusText(),
- style = TextStyle(
- color = if (activity.isRecentOrPlaying()) {
- GlanceTheme.colors.primary
- } else {
- GlanceTheme.colors.onSurfaceVariant
- },
- fontSize = 10.sp
- ),
- maxLines = 1
- )
+
+ private fun loadFriendImage(views: RemoteViews, cachedImagePath: String) {
+ if (cachedImagePath.isNotEmpty()) {
+ val file = File(cachedImagePath)
+ if (file.exists() && file.canRead() && file.length() > 0) {
+ try {
+ val bitmap = BitmapFactory.decodeFile(cachedImagePath)
+ if (bitmap != null && !bitmap.isRecycled) {
+ views.setImageViewBitmap(R.id.friend_image, bitmap)
+ return
}
+ } catch (e: Exception) {
+ android.util.Log.e("PlaytivityWidget", "Error loading friend image", e)
}
}
}
+
+ // Fallback to default icon
+ views.setImageViewResource(R.id.friend_image, R.drawable.ic_person)
}
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetReceiver.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetReceiver.kt
index d0dbe9d..702fafd 100644
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetReceiver.kt
+++ b/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetReceiver.kt
@@ -1,8 +1,25 @@
package com.mliem.playtivity.widget
-import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
-class PlaytivityWidgetReceiver : GlanceAppWidgetReceiver() {
- override val glanceAppWidget: GlanceAppWidget = PlaytivityAppWidget()
+class PlaytivityWidgetReceiver : AppWidgetProvider() {
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ for (appWidgetId in appWidgetIds) {
+ PlaytivityWidgetProvider.updateAppWidget(context, appWidgetManager, appWidgetId)
+ }
+ }
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ android.util.Log.d("PlaytivityWidget", "Widget enabled")
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ android.util.Log.d("PlaytivityWidget", "Widget disabled")
+ }
}
diff --git a/android/app/src/main/kotlin/com/mliem/playtivity/widget/RefreshWidgetCallback.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/RefreshWidgetCallback.kt
deleted file mode 100644
index db10aef..0000000
--- a/android/app/src/main/kotlin/com/mliem/playtivity/widget/RefreshWidgetCallback.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.mliem.playtivity.widget
-
-import android.content.Context
-import androidx.glance.GlanceId
-import androidx.glance.action.ActionParameters
-import androidx.glance.appwidget.action.ActionCallback
-import es.antonborri.home_widget.HomeWidgetBackgroundIntent
-import android.net.Uri
-
-/**
- * Action callback to refresh the widget
- */
-class RefreshWidgetCallback : ActionCallback {
- override suspend fun onAction(
- context: Context,
- glanceId: GlanceId,
- parameters: ActionParameters
- ) {
- // Trigger a background intent to refresh the widget data
- val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast(
- context,
- Uri.parse("playtivity://refresh")
- )
- backgroundIntent.send()
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_refresh.xml b/android/app/src/main/res/drawable/ic_refresh.xml
index 5db11ec..e191566 100644
--- a/android/app/src/main/res/drawable/ic_refresh.xml
+++ b/android/app/src/main/res/drawable/ic_refresh.xml
@@ -1,9 +1,10 @@
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/widget_activity_item.xml b/android/app/src/main/res/layout/widget_activity_item.xml
new file mode 100644
index 0000000..79365f5
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_activity_item.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/widget_layout.xml b/android/app/src/main/res/layout/widget_layout.xml
new file mode 100644
index 0000000..2adab43
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_layout.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 1b199e7..122d98e 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,13 +1,13 @@
- #CC1A1A1A
+ #E6000000
#FFFFFF
- #B3FFFFFF
- #80FFFFFF
+ #E6FFFFFF
+ #B3FFFFFF
#1DB954
- #33FFFFFF
- #1AFFFFFF
+ #4D000000
+ #33000000
#FFE1F5FE
#FF81D4FA
#FF039BE5
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..713f5e8
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..8b47f1e
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ api.spotify.com
+ accounts.spotify.com
+ open.spotify.com
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/playtivity_widget_glance.xml b/android/app/src/main/res/xml/playtivity_widget_glance.xml
index 086ed35..ba09556 100644
--- a/android/app/src/main/res/xml/playtivity_widget_glance.xml
+++ b/android/app/src/main/res/xml/playtivity_widget_glance.xml
@@ -10,5 +10,7 @@
android:maxResizeHeight="700dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
- android:updatePeriodMillis="1800000">
+ android:updatePeriodMillis="1800000"
+ android:initialLayout="@layout/widget_layout"
+ android:previewImage="@drawable/widget_preview">
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
index f018a61..f9246fd 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,3 +1,28 @@
-org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
-android.useAndroidX=true
+org.gradle.jvmargs=-Xmx8192m -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC
+org.gradle.parallel=true
+org.gradle.daemon=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+# Build Cache Location (optional)
+org.gradle.cache.dir=.gradle-cache
+
+# Android and Kotlin settings
+android.enableR8=true
android.enableJetifier=true
+android.useAndroidX=true
+android.useFullClasspathForDexingTransform=true
+android.enableResourceOptimizations=true
+kotlin.code.style=official
+
+# Flutter specific
+flutter.minSdkVersion=21
+flutter.targetSdkVersion=33
+flutter.compileSdkVersion=33
+
+# Kotlin build optimizations
+kapt.incremental.apt=true
+kapt.use.worker.api=true
+
+# Java version configuration
+android.compileOptions.sourceCompatibility=21
+android.compileOptions.targetCompatibility=21
diff --git a/build-apk.js b/build-apk.js
new file mode 100644
index 0000000..5bc8957
--- /dev/null
+++ b/build-apk.js
@@ -0,0 +1,319 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+class APKBuilder {
+ constructor() {
+ this.projectRoot = __dirname;
+ this.pubspecPath = path.join(this.projectRoot, 'pubspec.yaml');
+ this.outputDir = path.join(this.projectRoot, 'builds');
+ this.apkPath = path.join(this.projectRoot, 'build', 'app', 'outputs', 'flutter-apk', 'app-release.apk');
+ }
+
+ log(message, type = 'info') {
+ const timestamp = new Date().toLocaleTimeString();
+ const prefix = {
+ 'info': '๐ฑ',
+ 'success': 'โ
',
+ 'error': 'โ',
+ 'warning': 'โ ๏ธ',
+ 'build': '๐จ'
+ }[type] || 'โน๏ธ';
+
+ console.log(`[${timestamp}] ${prefix} ${message}`);
+ }
+
+ ensureOutputDirectory() {
+ if (!fs.existsSync(this.outputDir)) {
+ fs.mkdirSync(this.outputDir, { recursive: true });
+ this.log(`Created output directory: ${this.outputDir}`);
+ }
+ }
+
+ readPubspec() {
+ try {
+ const content = fs.readFileSync(this.pubspecPath, 'utf8');
+ return content;
+ } catch (error) {
+ throw new Error(`Failed to read pubspec.yaml: ${error.message}`);
+ }
+ }
+
+ writePubspec(content) {
+ try {
+ fs.writeFileSync(this.pubspecPath, content, 'utf8');
+ } catch (error) {
+ throw new Error(`Failed to write pubspec.yaml: ${error.message}`);
+ }
+ }
+
+ parseVersion(versionLine) {
+ // Extract version like "0.0.1+1" from "version: 0.0.1+1"
+ const match = versionLine.match(/version:\s*(.+)/);
+ if (!match) {
+ throw new Error('Invalid version format in pubspec.yaml');
+ }
+
+ const fullVersion = match[1].trim();
+ const [versionName, buildNumber] = fullVersion.split('+');
+
+ return {
+ versionName: versionName || '0.0.1',
+ buildNumber: parseInt(buildNumber) || 1,
+ fullVersion
+ };
+ }
+
+ incrementVersion(incrementType = 'build') {
+ this.log('Reading current version from pubspec.yaml');
+
+ const content = this.readPubspec();
+ const lines = content.split('\n');
+
+ let versionLineIndex = -1;
+ let currentVersion = null;
+
+ // Find the version line
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith('version:')) {
+ versionLineIndex = i;
+ currentVersion = this.parseVersion(lines[i]);
+ break;
+ }
+ }
+
+ if (versionLineIndex === -1) {
+ throw new Error('Version line not found in pubspec.yaml');
+ }
+
+ this.log(`Current version: ${currentVersion.fullVersion}`);
+
+ let newVersionName = currentVersion.versionName;
+ let newBuildNumber = currentVersion.buildNumber;
+
+ // Increment based on type
+ switch (incrementType) {
+ case 'major':
+ const [major, minor, patch] = newVersionName.split('.');
+ newVersionName = `${parseInt(major) + 1}.0.0`;
+ newBuildNumber = 1;
+ break;
+ case 'minor':
+ const [maj, min, pat] = newVersionName.split('.');
+ newVersionName = `${maj}.${parseInt(min) + 1}.0`;
+ newBuildNumber = 1;
+ break;
+ case 'patch':
+ const [ma, mi, pa] = newVersionName.split('.');
+ newVersionName = `${ma}.${mi}.${parseInt(pa) + 1}`;
+ newBuildNumber = 1;
+ break;
+ case 'build':
+ default:
+ newBuildNumber = currentVersion.buildNumber + 1;
+ break;
+ }
+
+ const newFullVersion = `${newVersionName}+${newBuildNumber}`;
+
+ // Update the version line
+ lines[versionLineIndex] = `version: ${newFullVersion}`;
+
+ // Write back to file
+ this.writePubspec(lines.join('\n'));
+
+ this.log(`Version updated to: ${newFullVersion}`, 'success');
+
+ return {
+ versionName: newVersionName,
+ buildNumber: newBuildNumber,
+ fullVersion: newFullVersion
+ };
+ }
+
+ runCommand(command, description) {
+ this.log(`${description}...`, 'build');
+ try {
+ const output = execSync(command, {
+ cwd: this.projectRoot,
+ stdio: 'pipe',
+ encoding: 'utf8'
+ });
+ this.log(`${description} completed`, 'success');
+ return output;
+ } catch (error) {
+ this.log(`${description} failed: ${error.message}`, 'error');
+ throw error;
+ }
+ }
+
+ buildAPK() {
+ this.log('Starting APK build process', 'build');
+
+ // Check if FVM is available, otherwise use flutter directly
+ let flutterCommand = 'flutter';
+ try {
+ execSync('fvm --version', { stdio: 'pipe' });
+ flutterCommand = 'fvm flutter';
+ this.log('Using FVM for Flutter commands');
+ } catch (error) {
+ this.log('FVM not found, using system Flutter');
+ }
+
+ try {
+ // Get dependencies
+ this.runCommand(`${flutterCommand} pub get`, 'Getting dependencies');
+
+ // Clean previous builds
+ this.runCommand(`${flutterCommand} clean`, 'Cleaning previous builds');
+
+ // Run tests (optional, continue on failure)
+ try {
+ this.runCommand(`${flutterCommand} test`, 'Running tests');
+ } catch (error) {
+ this.log('Tests failed, but continuing with build', 'warning');
+ }
+
+ // Build APK
+ this.runCommand(`${flutterCommand} build apk --release`, 'Building APK');
+
+ return true;
+ } catch (error) {
+ this.log(`Build failed: ${error.message}`, 'error');
+ return false;
+ }
+ }
+
+ copyAPK(version) {
+ if (!fs.existsSync(this.apkPath)) {
+ throw new Error(`APK not found at: ${this.apkPath}`);
+ }
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
+ const fileName = `playtivity-v${version.versionName}-build${version.buildNumber}-${timestamp}.apk`;
+ const destinationPath = path.join(this.outputDir, fileName);
+
+ fs.copyFileSync(this.apkPath, destinationPath);
+
+ this.log(`APK copied to: ${destinationPath}`, 'success');
+
+ // Also create a "latest" symlink/copy for convenience
+ const latestPath = path.join(this.outputDir, 'playtivity-latest.apk');
+ if (fs.existsSync(latestPath)) {
+ fs.unlinkSync(latestPath);
+ }
+ fs.copyFileSync(this.apkPath, latestPath);
+
+ return {
+ timestampedPath: destinationPath,
+ latestPath: latestPath,
+ fileName: fileName
+ };
+ }
+
+ getAPKInfo(apkPath) {
+ try {
+ const stats = fs.statSync(apkPath);
+ const sizeInMB = (stats.size / (1024 * 1024)).toFixed(2);
+ return {
+ size: sizeInMB,
+ created: stats.birthtime.toLocaleString()
+ };
+ } catch (error) {
+ return { size: 'Unknown', created: 'Unknown' };
+ }
+ }
+
+ async build(incrementType = 'build') {
+ try {
+ this.log('๐ Starting Playtivity APK Build Process');
+
+ // Ensure output directory exists
+ this.ensureOutputDirectory();
+
+ // Increment version
+ const newVersion = this.incrementVersion(incrementType);
+
+ // Build APK
+ const buildSuccess = this.buildAPK();
+
+ if (!buildSuccess) {
+ this.log('Build failed, exiting', 'error');
+ process.exit(1);
+ }
+
+ // Copy APK to output directory
+ const apkInfo = this.copyAPK(newVersion);
+ const fileInfo = this.getAPKInfo(apkInfo.timestampedPath);
+
+ // Success summary
+ this.log('๐ Build completed successfully!', 'success');
+ console.log('\n๐ Build Summary:');
+ console.log(` Version: ${newVersion.fullVersion}`);
+ console.log(` APK Size: ${fileInfo.size} MB`);
+ console.log(` Location: ${apkInfo.timestampedPath}`);
+ console.log(` Latest: ${apkInfo.latestPath}`);
+ console.log('\n๐ฑ Installation:');
+ console.log(` adb install "${apkInfo.latestPath}"`);
+ console.log(' or transfer to your device and install manually');
+
+ } catch (error) {
+ this.log(`Build process failed: ${error.message}`, 'error');
+ process.exit(1);
+ }
+ }
+}
+
+// CLI interface
+function showHelp() {
+ console.log(`
+๐จ Playtivity APK Builder
+
+Usage: node build-apk.js [increment-type]
+
+Increment Types:
+ build (default) - Increment build number only (0.0.1+1 โ 0.0.1+2)
+ patch - Increment patch version (0.0.1+5 โ 0.0.2+1)
+ minor - Increment minor version (0.1.2+3 โ 0.2.0+1)
+ major - Increment major version (1.2.3+4 โ 2.0.0+1)
+
+Examples:
+ node build-apk.js # Increment build number
+ node build-apk.js build # Same as above
+ node build-apk.js patch # Increment patch version
+ node build-apk.js minor # Increment minor version
+ node build-apk.js major # Increment major version
+
+The script will:
+ โ
Automatically increment the version in pubspec.yaml
+ โ
Clean and build the APK
+ โ
Copy the APK to builds/ folder with timestamp
+ โ
Create a "latest" APK for easy installation
+`);
+}
+
+// Main execution
+if (require.main === module) {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ showHelp();
+ process.exit(0);
+ }
+
+ const incrementType = args[0] || 'build';
+ const validTypes = ['build', 'patch', 'minor', 'major'];
+
+ if (!validTypes.includes(incrementType)) {
+ console.error(`โ Invalid increment type: ${incrementType}`);
+ console.error(`Valid types: ${validTypes.join(', ')}`);
+ process.exit(1);
+ }
+
+ const builder = new APKBuilder();
+ builder.build(incrementType);
+}
+
+module.exports = APKBuilder;
diff --git a/ios/PlaytivityWidget/PlaytivityWidget.swift b/ios/PlaytivityWidget/PlaytivityWidget.swift
index 07b820c..2cc03de 100644
--- a/ios/PlaytivityWidget/PlaytivityWidget.swift
+++ b/ios/PlaytivityWidget/PlaytivityWidget.swift
@@ -92,40 +92,80 @@ struct PlaytivityWidgetEntryView: View {
VStack(spacing: 3) {
ForEach(Array(entry.friendsActivities.prefix(maxFriendsToShow).enumerated()), id: \.offset) { index, activity in
- HStack(spacing: 8) {
- Image(systemName: "person.circle.fill")
- .foregroundColor(.white.opacity(0.5))
- .font(.system(size: 12))
-
- VStack(alignment: .leading, spacing: 1) {
- Text(activity.name)
- .font(.system(size: 11, weight: .bold))
- .foregroundColor(.white)
- .lineLimit(1)
-
- Text("\(activity.friendName) โข \(activity.artist)")
- .font(.system(size: 10))
+ if let url = URL(string: "spotify:user:\(activity.userId)") {
+ Link(destination: url) {
+ HStack(spacing: 8) {
+ Image(systemName: "person.circle.fill")
+ .foregroundColor(.white.opacity(0.5))
+ .font(.system(size: 12))
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text(activity.name)
+ .font(.system(size: 11, weight: .bold))
+ .foregroundColor(.white)
+ .lineLimit(1)
+
+ Text("\(activity.friendName) โข \(activity.artist)")
+ .font(.system(size: 10))
+ .foregroundColor(.white.opacity(0.5))
+ .lineLimit(1)
+
+ // Add status row with icon and text
+ HStack(spacing: 2) {
+ Image(systemName: activity.isRecentOrPlaying() ? "play.circle.fill" : "clock")
+ .font(.system(size: 9))
+ .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+
+ Text(activity.getStatusText())
+ .font(.system(size: 9))
+ .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+ .lineLimit(1)
+ }
+ }
+
+ Spacer()
+ }
+ .padding(6)
+ .background(Color.white.opacity(0.1))
+ .cornerRadius(8)
+ }
+ } else {
+ // Fallback for invalid URLs - non-clickable version
+ HStack(spacing: 8) {
+ Image(systemName: "person.circle.fill")
.foregroundColor(.white.opacity(0.5))
- .lineLimit(1)
+ .font(.system(size: 12))
- // Add status row with icon and text
- HStack(spacing: 2) {
- Image(systemName: activity.isRecentOrPlaying() ? "play.circle.fill" : "clock")
- .font(.system(size: 9))
- .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+ VStack(alignment: .leading, spacing: 1) {
+ Text(activity.name)
+ .font(.system(size: 11, weight: .bold))
+ .foregroundColor(.white)
+ .lineLimit(1)
- Text(activity.getStatusText())
- .font(.system(size: 9))
- .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+ Text("\(activity.friendName) โข \(activity.artist)")
+ .font(.system(size: 10))
+ .foregroundColor(.white.opacity(0.5))
.lineLimit(1)
+
+ // Add status row with icon and text
+ HStack(spacing: 2) {
+ Image(systemName: activity.isRecentOrPlaying() ? "play.circle.fill" : "clock")
+ .font(.system(size: 9))
+ .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+
+ Text(activity.getStatusText())
+ .font(.system(size: 9))
+ .foregroundColor(activity.isRecentOrPlaying() ? Color(red: 29/255, green: 185/255, blue: 84/255) : .white.opacity(0.4))
+ .lineLimit(1)
+ }
}
+
+ Spacer()
}
-
- Spacer()
+ .padding(6)
+ .background(Color.white.opacity(0.1))
+ .cornerRadius(8)
}
- .padding(6)
- .background(Color.white.opacity(0.1))
- .cornerRadius(8)
}
}
}
@@ -168,7 +208,8 @@ struct PlaytivityWidget_Previews: PreviewProvider {
albumArt: "",
timestamp: Int64(Date().timeIntervalSince1970 * 1000) - 120000, // 2 minutes ago
isCurrentlyPlaying: false,
- activityType: "track"
+ activityType: "track",
+ userId: "alice123"
),
FriendActivity(
name: "As It Was",
@@ -177,7 +218,8 @@ struct PlaytivityWidget_Previews: PreviewProvider {
albumArt: "",
timestamp: Int64(Date().timeIntervalSince1970 * 1000) - 30000, // 30 seconds ago (recent)
isCurrentlyPlaying: true,
- activityType: "track"
+ activityType: "track",
+ userId: "bob456"
)
]
))
diff --git a/ios/PlaytivityWidget/WidgetDataProvider.swift b/ios/PlaytivityWidget/WidgetDataProvider.swift
index a128eb3..ea4c0e4 100644
--- a/ios/PlaytivityWidget/WidgetDataProvider.swift
+++ b/ios/PlaytivityWidget/WidgetDataProvider.swift
@@ -15,6 +15,7 @@ struct FriendActivity {
let timestamp: Int64
let isCurrentlyPlaying: Bool
let activityType: String
+ let userId: String
func getStatusText() -> String {
let currentTime = Int64(Date().timeIntervalSince1970 * 1000) // Current time in milliseconds
@@ -138,6 +139,7 @@ struct Provider: TimelineProvider {
let timestampString = userDefaults?.string(forKey: "friend_\(i)_timestamp") ?? "0"
let isCurrentlyPlayingString = userDefaults?.string(forKey: "friend_\(i)_is_currently_playing") ?? "false"
let activityType = userDefaults?.string(forKey: "friend_\(i)_activity_type") ?? "track"
+ let userId = userDefaults?.string(forKey: "friend_\(i)_user_id") ?? ""
let timestamp = Int64(timestampString) ?? 0
let isCurrentlyPlaying = Bool(isCurrentlyPlayingString) ?? false
@@ -150,7 +152,8 @@ struct Provider: TimelineProvider {
albumArt: friendAlbumArt,
timestamp: timestamp,
isCurrentlyPlaying: isCurrentlyPlaying,
- activityType: activityType
+ activityType: activityType,
+ userId: userId
))
}
}
diff --git a/lib/main.dart b/lib/main.dart
index 111ae7d..3bfea68 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -12,8 +12,11 @@ import 'screens/settings_screen.dart';
import 'services/widget_service.dart';
import 'services/background_service.dart';
import 'services/http_interceptor.dart';
+import 'services/update_service.dart';
import 'utils/theme.dart';
import 'utils/auth_utils.dart';
+import 'dart:async';
+import 'services/app_logger.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -25,14 +28,39 @@ void main() async {
// Initialize background service
await BackgroundService.initialize();
+ // Check for updates on startup if needed
+ _checkForUpdatesOnStartup();
+
runApp(MyApp(prefs: prefs));
}
+// Check for updates on app startup if enough time has passed
+Future _checkForUpdatesOnStartup() async {
+ try {
+ // First, auto-enable nightly builds if applicable
+ await UpdateService.autoEnableNightlyIfApplicable();
+
+ // Check if we should check for updates
+ if (await UpdateService.shouldCheckForUpdates()) {
+ // Check for updates in the background
+ final updateResult = await UpdateService.checkForUpdates();
+
+ // Store the result for later use
+ if (updateResult.hasUpdate) {
+ // We'll handle the update notification in the app UI later
+ AppLogger.info('Update available: ${updateResult.updateInfo?.version}');
+ }
+ }
+ } catch (e) {
+ // Ignore errors during startup, we don't want to block app launch
+ AppLogger.error('Error checking for updates on startup', e);
+ }
+}
+
class MyApp extends StatelessWidget {
final SharedPreferences prefs;
const MyApp({super.key, required this.prefs});
-
@override
Widget build(BuildContext context) {
return MultiProvider(
@@ -48,22 +76,24 @@ class MyApp extends StatelessWidget {
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeProvider.themeMode,
+ // Add the update checker wrapper
+ builder: (context, child) => _UpdateCheckerWrapper(child: child!),
home: const AppWrapper(),
routes: {
'/login': (context) {
- print('๐ Navigating to LoginScreen via route');
+ AppLogger.info('๐ Navigating to LoginScreen via route');
return const LoginScreen();
},
'/home': (context) {
- print('๐ Navigating to HomeScreen via route');
+ AppLogger.info('๐ Navigating to HomeScreen via route');
return const HomeScreen();
},
'/profile': (context) {
- print('๐ Navigating to ProfileScreen via route');
+ AppLogger.info('๐ Navigating to ProfileScreen via route');
return const ProfileScreen();
},
'/settings': (context) {
- print('๐ Navigating to SettingsScreen via route');
+ AppLogger.info('๐ Navigating to SettingsScreen via route');
return const SettingsScreen();
},
},
@@ -82,6 +112,7 @@ class AppWrapper extends StatefulWidget {
}
class _AppWrapperState extends State {
+
@override
void dispose() {
// Clear the context when the widget is disposed
@@ -97,28 +128,34 @@ class _AppWrapperState extends State {
return Consumer(
builder: (context, authProvider, child) {
// Debug logging for troubleshooting
- print('๐ AppWrapper rebuild - AuthProvider state:');
- print(' - isInitialized: ${authProvider.isInitialized}');
- print(' - isLoading: ${authProvider.isLoading}');
- print(' - isAuthenticated: ${authProvider.isAuthenticated}');
- print(' - currentUser: ${authProvider.currentUser?.displayName ?? 'null'}');
- print(' - bearerToken exists: ${authProvider.bearerToken != null}');
+ AppLogger.debug('๐ AppWrapper rebuild - AuthProvider state:');
+ AppLogger.debug(' - isInitialized: ${authProvider.isInitialized}');
+ AppLogger.debug(' - isLoading: ${authProvider.isLoading}');
+ AppLogger.debug(' - isAuthenticated: ${authProvider.isAuthenticated}');
+ AppLogger.debug(' - currentUser: ${authProvider.currentUser?.displayName ?? 'null'}');
+ AppLogger.debug(' - bearerToken exists: ${authProvider.bearerToken != null}');
+
+ // Only verify authentication state on app resume, not immediately after login
+ // This prevents the user from being kicked out right after successful login
+ // The verification will happen when the app is resumed from background
// Show loading screen while authentication is being initialized or in progress
if (!authProvider.isInitialized || authProvider.isLoading) {
- print('๐ฑ Showing loading screen...');
+ AppLogger.info('๐ฑ Showing loading screen...');
return const Scaffold(
- body: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- CircularProgressIndicator(),
- SizedBox(height: 16),
- Text(
- 'Loading...',
- style: TextStyle(fontSize: 16),
- ),
- ],
+ body: SafeArea(
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ CircularProgressIndicator(),
+ SizedBox(height: 16),
+ Text(
+ 'Loading...',
+ style: TextStyle(fontSize: 16),
+ ),
+ ],
+ ),
),
),
);
@@ -126,13 +163,13 @@ class _AppWrapperState extends State {
// Add explicit check with fallback
final isAuth = authProvider.isAuthenticated;
- print('๐ Final authentication decision: $isAuth');
+ AppLogger.debug('๐ Final authentication decision: $isAuth');
if (isAuth) {
- print('๐ฑ Showing MainNavigationScreen...');
+ AppLogger.info('๐ฑ Showing MainNavigationScreen...');
return const MainNavigationScreen();
} else {
- print('๐ฑ Showing LoginScreen...');
+ AppLogger.info('๐ฑ Showing LoginScreen...');
return const LoginScreen();
}
},
@@ -147,7 +184,7 @@ class MainNavigationScreen extends StatefulWidget {
State createState() => _MainNavigationScreenState();
}
-class _MainNavigationScreenState extends State {
+class _MainNavigationScreenState extends State with WidgetsBindingObserver {
int _currentIndex = 0;
final List _screens = [
@@ -158,7 +195,10 @@ class _MainNavigationScreenState extends State {
@override
void initState() {
super.initState();
- print('๐ MainNavigationScreen initialized');
+ AppLogger.info('๐ MainNavigationScreen initialized');
+
+ // Add lifecycle observer
+ WidgetsBinding.instance.addObserver(this);
// Register background widget updates when user is authenticated
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -166,18 +206,61 @@ class _MainNavigationScreenState extends State {
});
}
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ super.didChangeAppLifecycleState(state);
+
+ if (state == AppLifecycleState.resumed) {
+ AppLogger.info('๐ฑ App resumed, verifying authentication...');
+ // Verify authentication when app resumes, but only if we're not in an active login flow
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ if (mounted) {
+ final authProvider = Provider.of(context, listen: false);
+
+ // Skip verification if user is actively logging in or if auth provider is not initialized
+ if (authProvider.isLoading || !authProvider.isInitialized) {
+ AppLogger.info('โ ๏ธ Skipping auth verification - login in progress or not initialized');
+ return;
+ }
+
+ // Only verify if we think we should be authenticated
+ if (authProvider.isAuthenticated) {
+ AppLogger.info('๐ Verifying existing authentication...');
+ final isValid = await authProvider.verifyAndRefreshAuth();
+ if (!isValid) {
+ AppLogger.info('โ ๏ธ Authentication invalid after app resume - clearing state');
+ // Clear the authentication state but don't force logout
+ // This allows the user to login again if needed
+ await authProvider.resetAuthenticationState();
+ } else {
+ AppLogger.info('โ
Authentication verified successfully after app resume');
+ }
+ } else {
+ AppLogger.info('โน๏ธ No authentication to verify - user not logged in');
+ }
+ }
+ });
+ }
+ }
+
Future _registerBackgroundUpdates() async {
try {
await BackgroundService.registerWidgetUpdateTask();
- print('โ
Background widget updates registered');
+ AppLogger.info('โ
Background widget updates registered');
} catch (e) {
- print('โ Failed to register background updates: $e');
+ AppLogger.error('โ Failed to register background updates', e);
}
}
@override
Widget build(BuildContext context) {
- print('๐ MainNavigationScreen building with tab index: $_currentIndex');
+ AppLogger.debug('๐ MainNavigationScreen building with tab index: $_currentIndex');
return Consumer(
builder: (context, spotifyProvider, child) {
@@ -205,7 +288,7 @@ class _MainNavigationScreenState extends State {
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
- color: Colors.black.withOpacity(0.1),
+ color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
@@ -238,3 +321,142 @@ class _MainNavigationScreenState extends State {
);
}
}
+
+// Widget wrapper to check for updates and show notifications
+class _UpdateCheckerWrapper extends StatefulWidget {
+ final Widget child;
+
+ const _UpdateCheckerWrapper({
+ required this.child,
+ });
+
+ @override
+ State<_UpdateCheckerWrapper> createState() => _UpdateCheckerWrapperState();
+}
+
+class _UpdateCheckerWrapperState extends State<_UpdateCheckerWrapper> {
+ UpdateInfo? _updateInfo;
+ bool _hasCheckedForUpdates = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // Delay the update check to avoid affecting app startup performance
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _checkForUpdates();
+ });
+ }
+
+ Future _checkForUpdates() async {
+ try {
+ // Only check once per app session
+ if (_hasCheckedForUpdates) return;
+ _hasCheckedForUpdates = true;
+
+ // Check if auto-download is enabled
+ final autoDownload = await UpdateService.getAutoDownloadPreference();
+
+ // Check for updates
+ final updateResult = await UpdateService.checkForUpdates();
+
+ // If update available, show banner
+ if (updateResult.hasUpdate && updateResult.updateInfo != null) {
+ AppLogger.info('Update available: ${updateResult.updateInfo?.version}');
+ setState(() {
+ _updateInfo = updateResult.updateInfo;
+ });
+
+ // If auto-download is enabled, download immediately
+ if (autoDownload && mounted) {
+ AppLogger.info('Auto-download enabled, starting download...');
+ _handleUpdateDownload();
+ }
+ }
+ } catch (e) {
+ AppLogger.error('Error checking for updates', e);
+ }
+ }
+
+ // Handle downloading an update
+ Future _handleUpdateDownload() async {
+ if (_updateInfo == null || !mounted) return;
+
+ // Show download dialog and get the downloaded file path
+ final filePath = await UpdateService.showDownloadDialog(
+ context,
+ _updateInfo!,
+ );
+
+ if (filePath != null && mounted) {
+ // Show installation dialog
+ await UpdateService.showInstallDialog(context, filePath);
+
+ // Clear update info if user cancels installation
+ setState(() {
+ _updateInfo = null;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // If no update available, just return the child
+ if (_updateInfo == null) {
+ return widget.child;
+ }
+
+ // If update is available, show a notification banner
+ return Material(
+ child: Column(
+ children: [
+ // Update notification banner at the top
+ Container(
+ color: _updateInfo!.isNightly ? Colors.orange.shade700 : Theme.of(context).primaryColor,
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+ child: SafeArea(
+ bottom: false,
+ child: Row(
+ children: [
+ Icon(
+ _updateInfo!.isNightly ? Icons.science : Icons.system_update,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ _updateInfo!.isNightly
+ ? 'New nightly build available: ${_updateInfo!.version}'
+ : 'Update available: ${_updateInfo!.version}',
+ style: const TextStyle(color: Colors.white),
+ ),
+ ),
+ TextButton(
+ onPressed: _handleUpdateDownload,
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.white,
+ backgroundColor: Colors.white.withValues(alpha: 0.2),
+ ),
+ child: const Text('Update'),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close, color: Colors.white),
+ onPressed: () {
+ setState(() {
+ _updateInfo = null;
+ });
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Main app content
+ Expanded(
+ child: widget.child,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart
index 8f50da6..a279a11 100644
--- a/lib/providers/auth_provider.dart
+++ b/lib/providers/auth_provider.dart
@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:provider/provider.dart';
import '../services/spotify_buddy_service.dart';
import '../models/user.dart';
import '../services/spotify_service.dart';
+import '../services/app_logger.dart';
+import 'spotify_provider.dart';
class AuthProvider extends ChangeNotifier {
static const String _bearerTokenKey = 'spotify_bearer_token';
@@ -25,17 +28,19 @@ class AuthProvider extends ChangeNotifier {
bool get isAuthenticated {
if (!_isInitialized) {
- print('๐ isAuthenticated: false (not initialized)');
+ AppLogger.auth('isAuthenticated: false (not initialized)');
return false;
}
// Check for Bearer token authentication
- final tokenAuth = _bearerToken != null && _currentUser != null;
+ final hasToken = _bearerToken != null && _bearerToken!.isNotEmpty;
+ final hasUser = _currentUser != null;
- print('๐ isAuthenticated evaluation:');
- print(' - Token valid: $tokenAuth (token: ${_bearerToken != null}, user: ${_currentUser != null})');
- print(' - User: ${_currentUser?.displayName ?? 'none'}');
- print(' - Final result: $tokenAuth');
+ // Also verify the buddy service has the token
+ final buddyServiceToken = _buddyService.getBearerToken();
+ final buddyServiceHasToken = buddyServiceToken != null && buddyServiceToken.isNotEmpty;
+
+ final tokenAuth = hasToken && hasUser && buddyServiceHasToken;
return tokenAuth;
}
@@ -47,7 +52,7 @@ class AuthProvider extends ChangeNotifier {
Future _initializeAuth() async {
try {
- print('๐ Starting authentication initialization...');
+ AppLogger.auth('Starting authentication initialization...');
_loadStoredData();
// Load saved Bearer token and headers if available
@@ -60,37 +65,73 @@ class AuthProvider extends ChangeNotifier {
_bearerToken = savedBearerToken;
_headers = Map.from(json.decode(savedHeadersJson));
_buddyService.setBearerToken(savedBearerToken, _headers!);
- print('โ
Restored saved Bearer token and headers');
+ AppLogger.auth('Restored saved Bearer token and headers');
// Try to load saved user profile
if (savedUserJson != null) {
final userMap = json.decode(savedUserJson);
_currentUser = User.fromJson(userMap);
- print('โ
Restored saved user profile: ${_currentUser!.displayName}');
+ AppLogger.auth('Restored saved user profile: ${_currentUser!.displayName}');
+
+ // Validate the restored authentication by making a quick API call
+ // This helps detect if tokens have expired while app was backgrounded
+ try {
+ AppLogger.auth('Validating restored authentication...');
+ final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
+ if (testUser != null && testUser.id == _currentUser!.id) {
+ AppLogger.auth('Restored authentication is valid');
+ } else {
+ AppLogger.warning('Restored authentication validation failed - clearing stored data');
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ }
+ } catch (e) {
+ AppLogger.warning('Authentication validation failed: $e - clearing stored data');
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ }
} else {
// Fetch user profile using Bearer token
- print('๐ Fetching user profile with Bearer token...');
- _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
- if (_currentUser != null) {
- print('โ
Successfully loaded user profile: ${_currentUser!.displayName}');
+ AppLogger.auth('Fetching user profile with Bearer token...');
+ try {
+ _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
+ if (_currentUser != null) {
+ AppLogger.auth('Successfully loaded user profile: ${_currentUser!.displayName}');
+ // Save the user profile for next time
+ await _prefs.setString(_userKey, json.encode(_currentUser!.toJson()));
+ } else {
+ AppLogger.warning('Failed to load user profile - clearing stored data');
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
+ }
+ } catch (e) {
+ AppLogger.warning('Failed to load user profile: $e - clearing stored data');
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
}
}
} catch (e) {
- print('โ ๏ธ Failed to restore saved authentication: $e');
+ AppLogger.warning('Failed to restore saved authentication: $e');
await _clearStoredData();
}
}
- print('๐ Initialization complete. Final state:');
- print(' - Has Bearer token: ${_bearerToken != null}');
- print(' - Has user: ${_currentUser != null}');
- print(' - User name: ${_currentUser?.displayName ?? 'none'}');
+ AppLogger.auth('Initialization complete. Final state:');
+ AppLogger.auth(' - Has Bearer token: ${_bearerToken != null}');
+ AppLogger.auth(' - Has user: ${_currentUser != null}');
+ AppLogger.auth(' - User name: ${_currentUser?.displayName ?? 'none'}');
} catch (e) {
- print('โ Error during auth initialization: $e');
+ AppLogger.error('Error during auth initialization', e);
} finally {
_isInitialized = true;
- print('โ
AuthProvider initialization completed');
+ AppLogger.auth('AuthProvider initialization completed');
notifyListeners();
// Additional notification after a short delay to ensure UI updates
@@ -104,13 +145,13 @@ class AuthProvider extends ChangeNotifier {
void _loadStoredData() {
// Clean up old data during migration
- print('๐งน Cleaning up old authentication data...');
+ AppLogger.auth('Cleaning up old authentication data...');
_prefs.remove('spotify_sp_dc_cookie');
_prefs.remove('spotify_access_token');
_prefs.remove('spotify_refresh_token');
_prefs.remove('spotify_token_expiry');
- print('๐ฆ Old authentication data cleaned up');
+ AppLogger.auth('Old authentication data cleaned up');
}
void startLogin() {
@@ -125,14 +166,19 @@ class AuthProvider extends ChangeNotifier {
Future handleAuthComplete(String bearerToken, Map headers) async {
try {
- print('๐ handleAuthComplete called with:');
- print(' - bearerToken: "${bearerToken.substring(0, 20)}..." (length: ${bearerToken.length})');
- print(' - headers: ${headers.keys.join(', ')}');
- print(' - headers Cookie exists: ${headers.containsKey('Cookie')}');
- print(' - headers Cookie value: ${headers['Cookie']?.substring(0, 100) ?? 'null'}...');
- print(' - headers Cookie length: ${headers['Cookie']?.length ?? 0}');
+ AppLogger.auth('handleAuthComplete called with:');
+ AppLogger.auth(' - bearerToken: "${bearerToken.substring(0, 20)}..." (length: ${bearerToken.length})');
+ AppLogger.auth(' - headers: ${headers.keys.join(', ')}');
+ AppLogger.auth(' - headers Cookie exists: ${headers.containsKey('Cookie')}');
+ AppLogger.auth(' - headers Cookie value: ${headers['Cookie']?.substring(0, 100) ?? 'null'}...');
+ AppLogger.auth(' - headers Cookie length: ${headers['Cookie']?.length ?? 0}');
- print('๐ Processing Bearer token authentication...');
+ AppLogger.auth('Processing Bearer token authentication...');
+
+ // Validate the bearer token
+ if (bearerToken.isEmpty || bearerToken.length < 50) {
+ throw Exception('Invalid bearer token provided');
+ }
// Store Bearer token and headers
_bearerToken = bearerToken;
@@ -141,71 +187,126 @@ class AuthProvider extends ChangeNotifier {
// Set the Bearer token directly in buddy service
_buddyService.setBearerToken(bearerToken, headers);
- // Fetch user profile using Bearer token
- print('๐ Fetching user profile with Bearer token...');
- _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
- if (_currentUser != null) {
- print('โ
Successfully loaded user profile: ${_currentUser!.displayName}');
+ // Verify the buddy service has the token properly set
+ AppLogger.auth('Verifying buddy service token after setting...');
+ final buddyServiceToken = _buddyService.getBearerToken();
+ if (buddyServiceToken == null || buddyServiceToken.isEmpty) {
+ throw Exception('Failed to set Bearer token in buddy service');
} else {
- print('โ ๏ธ Failed to load user profile');
+ AppLogger.auth('Buddy service token verified: ${buddyServiceToken.substring(0, 20)}...');
}
-
+
+ // Fetch user profile using Bearer token with retry logic
+ AppLogger.auth('Fetching user profile with Bearer token...');
+ User? userProfile;
+
+ // Try multiple times to get user profile (sometimes the token needs a moment to propagate)
+ // Extended retry logic for long idle scenarios
+ for (int attempt = 1; attempt <= 5; attempt++) {
+ try {
+ userProfile = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
+ if (userProfile != null) {
+ AppLogger.auth('Successfully loaded user profile on attempt $attempt: ${userProfile.displayName}');
+ break;
+ }
+ } catch (e) {
+ AppLogger.warning('Failed to load user profile on attempt $attempt: $e');
+ if (attempt < 5) {
+ // Increase delay for later attempts to handle network issues
+ final delayMs = attempt <= 2 ? 1000 : 2000;
+ AppLogger.auth('Retrying in ${delayMs}ms...');
+ await Future.delayed(Duration(milliseconds: delayMs));
+ }
+ }
+ }
+
+ if (userProfile == null) {
+ throw Exception('Failed to load user profile after multiple attempts');
+ }
+
+ _currentUser = userProfile;
+
+ // Save the data to persistent storage
await _saveStoredData();
- print('โ
Bearer token authentication complete - token and user profile stored');
+ AppLogger.auth('Bearer token authentication complete - token and user profile stored');
- // Clear loading state and ensure initialization is complete
- _isLoading = false;
+ // Ensure initialization is complete and clear loading state
_isInitialized = true;
+ _isLoading = false;
- print('๐ AuthProvider state after completion:');
- print(' - isAuthenticated: $isAuthenticated');
- print(' - isInitialized: $_isInitialized');
- print(' - isLoading: $_isLoading');
- print(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
-
- // Force UI update with multiple notifications to ensure state propagation
- notifyListeners();
+ AppLogger.auth('AuthProvider state after completion:');
+ AppLogger.auth(' - isAuthenticated: $isAuthenticated');
+ AppLogger.auth(' - isInitialized: $_isInitialized');
+ AppLogger.auth(' - isLoading: $_isLoading');
+ AppLogger.auth(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
- // Add a short delay and notify again to handle any race conditions
- await Future.delayed(const Duration(milliseconds: 50));
+ // Notify listeners once with the final state
notifyListeners();
- // Final notification after a longer delay to ensure all consumers are updated
+ // Simplified verification - just check that we have the basic requirements
+ // Removed complex retry logic that was causing race conditions
await Future.delayed(const Duration(milliseconds: 100));
- notifyListeners();
+
+ // Final verification with more robust error handling for long idle scenarios
+ final hasToken = _bearerToken != null && _bearerToken!.isNotEmpty;
+ final hasUser = _currentUser != null;
+ final isInitComplete = _isInitialized;
+ final finalBuddyServiceToken = _buddyService.getBearerToken();
+ final buddyServiceHasToken = finalBuddyServiceToken != null && finalBuddyServiceToken.isNotEmpty;
+
+ AppLogger.auth('Final verification check:');
+ AppLogger.auth(' - hasToken: $hasToken');
+ AppLogger.auth(' - hasUser: $hasUser');
+ AppLogger.auth(' - isInitialized: $isInitComplete');
+ AppLogger.auth(' - buddyServiceHasToken: $buddyServiceHasToken');
+
+ if (!hasToken || !hasUser || !isInitComplete || !buddyServiceHasToken) {
+ throw Exception('Authentication verification failed after completion - Token: $hasToken, User: $hasUser, Init: $isInitComplete, BuddyService: $buddyServiceHasToken');
+ }
+
+ AppLogger.auth('Authentication flow completed successfully');
} catch (e) {
- print('โ Error in handleAuthComplete: $e');
+ AppLogger.error('Error in handleAuthComplete', e);
+
+ // Clean up on error
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
_isLoading = false;
+
+ // Ensure we clear any potentially corrupted stored data
+ await _clearStoredData();
+
notifyListeners();
rethrow;
}
}
Future _saveStoredData() async {
- print('๐พ Saving auth data to storage...');
+ AppLogger.auth('Saving auth data to storage...');
if (_bearerToken != null) {
await _prefs.setString(_bearerTokenKey, _bearerToken!);
- print('โ
Bearer token saved successfully');
+ AppLogger.auth('Bearer token saved successfully');
}
if (_headers != null) {
await _prefs.setString(_headersKey, json.encode(_headers!));
- print('โ
Headers saved successfully');
+ AppLogger.auth('Headers saved successfully');
}
if (_currentUser != null) {
await _prefs.setString(_userKey, json.encode(_currentUser!.toJson()));
- print('โ
User profile saved successfully');
+ AppLogger.auth('User profile saved successfully');
}
- print('โ
Auth data saved successfully');
+ AppLogger.auth('Auth data saved successfully');
}
Future _clearStoredData() async {
- print('๐๏ธ Clearing stored auth data...');
+ AppLogger.auth('Clearing stored auth data...');
await _prefs.remove(_bearerTokenKey);
await _prefs.remove(_headersKey);
@@ -215,11 +316,11 @@ class AuthProvider extends ChangeNotifier {
_headers = null;
_currentUser = null;
- print('โ
Stored auth data cleared');
+ AppLogger.auth('Stored auth data cleared');
}
Future logout() async {
- print('๐ช Logging out and clearing all auth data...');
+ AppLogger.auth('Logging out and clearing all auth data...');
// Set loading state to prevent race conditions
_isLoading = true;
@@ -227,18 +328,18 @@ class AuthProvider extends ChangeNotifier {
await _clearStoredData();
- // Clean up buddy service
+ // Clean up buddy service (this now clears all cached data)
_buddyService.clearBearerToken();
// Ensure all state is properly reset
_isLoading = false;
- print('โ
All auth data cleared successfully');
- print('๐ Post-logout state:');
- print(' - isAuthenticated: $isAuthenticated');
- print(' - isInitialized: $_isInitialized');
- print(' - isLoading: $_isLoading');
- print(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
+ AppLogger.auth('All auth data cleared successfully');
+ AppLogger.auth('Post-logout state:');
+ AppLogger.auth(' - isAuthenticated: $isAuthenticated');
+ AppLogger.auth(' - isInitialized: $_isInitialized');
+ AppLogger.auth(' - isLoading: $_isLoading');
+ AppLogger.auth(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
// Force multiple notifications to ensure UI updates
notifyListeners();
@@ -254,7 +355,7 @@ class AuthProvider extends ChangeNotifier {
/// Re-authenticate when token expires or is invalid
Future reAuthenticate() async {
- print('๐ Re-authentication requested...');
+ AppLogger.auth('Re-authentication requested...');
try {
// Clear old authentication data
@@ -268,11 +369,11 @@ class AuthProvider extends ChangeNotifier {
// Import the WebView login widget
// Note: This would need to be called from a UI context
// For now, we'll return false to indicate manual login is needed
- print('โ ๏ธ Re-authentication requires user interaction');
+ AppLogger.warning('Re-authentication requires user interaction');
return false;
} catch (e) {
- print('โ Error during re-authentication: $e');
+ AppLogger.error('Error during re-authentication', e);
return false;
} finally {
_isLoading = false;
@@ -283,26 +384,26 @@ class AuthProvider extends ChangeNotifier {
/// Check if token needs refresh and handle it
Future ensureValidAuthentication() async {
if (!isAuthenticated) {
- print('โ ๏ธ No valid authentication, re-authentication needed');
+ AppLogger.warning('No valid authentication, re-authentication needed');
return false;
}
// Test if current token is still valid by making a simple API call
try {
await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
- print('โ
Current authentication is valid');
+ AppLogger.auth('Current authentication is valid');
return true;
} catch (e) {
try {
// Fallback to SpotifyService
final spotifyService = SpotifyService();
await spotifyService.getCurrentUser(_bearerToken!);
- print('โ
Current authentication is valid (via fallback)');
+ AppLogger.auth('Current authentication is valid (via fallback)');
return true;
} catch (fallbackError) {
- print('โ ๏ธ Current authentication invalid: $e');
- print('โ ๏ธ Fallback also failed: $fallbackError');
- print('๐ Attempting re-authentication...');
+ AppLogger.warning('Current authentication invalid: $e');
+ AppLogger.warning('Fallback also failed: $fallbackError');
+ AppLogger.auth('Attempting re-authentication...');
return await reAuthenticate();
}
}
@@ -310,52 +411,160 @@ class AuthProvider extends ChangeNotifier {
/// Debug method to print current authentication state
void debugAuthState() {
- print('๐ === AuthProvider Debug State ===');
- print(' - isInitialized: $_isInitialized');
- print(' - isLoading: $_isLoading');
- print(' - isAuthenticated: $isAuthenticated');
- print(' - hasBearerToken: ${_bearerToken != null}');
- print(' - bearerToken: ${_bearerToken?.substring(0, 20) ?? 'null'}...');
- print(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
- print(' - currentUserId: ${_currentUser?.id ?? 'null'}');
- print(' - userEmail: ${_currentUser?.email ?? 'null'}');
- print('=================================');
+ AppLogger.auth('=== AuthProvider Debug State ===');
+ AppLogger.auth(' - isInitialized: $_isInitialized');
+ AppLogger.auth(' - isLoading: $_isLoading');
+ AppLogger.auth(' - isAuthenticated: $isAuthenticated');
+ AppLogger.auth(' - hasBearerToken: ${_bearerToken != null}');
+ AppLogger.auth(' - bearerToken: ${_bearerToken?.substring(0, 20) ?? 'null'}...');
+ AppLogger.auth(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
+ AppLogger.auth(' - currentUserId: ${_currentUser?.id ?? 'null'}');
+ AppLogger.auth(' - userEmail: ${_currentUser?.email ?? 'null'}');
+ AppLogger.auth('=================================');
+ }
+
+ /// Global method to reset authentication state after 401 errors
+ /// This can be called from anywhere in the app to recover from auth failures
+ Future resetAuthenticationState() async {
+ AppLogger.auth('Resetting authentication state after error...');
+
+ try {
+ // Clear all stored authentication data
+ await _clearStoredData();
+
+ // Clear buddy service state and caches
+ _buddyService.clearBearerToken();
+
+ // Reset internal state variables
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ _isLoading = false;
+ // Keep _isInitialized = true so the app doesn't show loading screen
+
+ AppLogger.auth('Authentication state reset completed');
+ AppLogger.auth('Post-reset state:');
+ AppLogger.auth(' - isAuthenticated: $isAuthenticated');
+ AppLogger.auth(' - isInitialized: $_isInitialized');
+ AppLogger.auth(' - bearerToken: ${_bearerToken ?? 'null'}');
+ AppLogger.auth(' - currentUser: ${_currentUser?.displayName ?? 'null'}');
+
+ // Notify all listeners that auth state has changed
+ notifyListeners();
+
+ // Additional notification to ensure all UI components update
+ await Future.delayed(const Duration(milliseconds: 50));
+ notifyListeners();
+
+ } catch (e) {
+ AppLogger.error('Error during authentication state reset', e);
+ // Even if there's an error, ensure we're in a clean state
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ _isLoading = false;
+ notifyListeners();
+ }
}
/// Force logout with complete state reset and navigation
Future forceLogoutAndNavigate(BuildContext context) async {
- print('๐ช Force logout initiated...');
+ if (!context.mounted) return;
+
+ AppLogger.auth('Force logout initiated...');
// Set loading state
_isLoading = true;
notifyListeners();
- // Clear all authentication data
+ // Clear SpotifyProvider state first
+ try {
+ final spotifyProvider = Provider.of(context, listen: false);
+ spotifyProvider.clearError();
+ spotifyProvider.clearAuthError();
+ AppLogger.auth('Cleared SpotifyProvider cached state');
+ } catch (e) {
+ AppLogger.warning('Could not clear SpotifyProvider state: $e');
+ }
+
+ // Clear all stored data
await _clearStoredData();
- _buddyService.clearBearerToken();
// Reset all state variables
- _isLoading = false;
_bearerToken = null;
_headers = null;
_currentUser = null;
- print('โ
All auth data cleared, forcing navigation...');
+ AppLogger.auth('All auth data cleared, forcing navigation...');
// Force multiple notifications
notifyListeners();
- // Navigate directly to login screen with complete stack clear
+ // Only navigate if the context is still valid
if (context.mounted) {
+ // Navigate to login screen and clear all routes
Navigator.of(context).pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
- print('โ
Navigation completed to login screen');
+ AppLogger.auth('Navigation completed to login screen');
}
- // Additional notifications after navigation
- await Future.delayed(const Duration(milliseconds: 100));
+ // Clear loading state
+ _isLoading = false;
notifyListeners();
}
+
+ /// Verify authentication state and refresh if needed
+ /// This can be called when app resumes or when we need to verify auth status
+ Future verifyAndRefreshAuth() async {
+ AppLogger.auth('Verifying and refreshing authentication state...');
+
+ if (!_isInitialized) {
+ AppLogger.warning('Auth not initialized yet, skipping verification');
+ return false;
+ }
+
+ // If we don't have basic auth data, we're not authenticated
+ if (_bearerToken == null || _currentUser == null) {
+ AppLogger.warning('Missing basic auth data (token or user)');
+ return false;
+ }
+
+ try {
+ // Test the current token by making an API call
+ AppLogger.auth('Testing current authentication with API call...');
+ final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!);
+
+ if (testUser != null && testUser.id == _currentUser!.id) {
+ AppLogger.auth('Authentication verified successfully');
+
+ // Update user info in case anything changed
+ _currentUser = testUser;
+ await _prefs.setString(_userKey, json.encode(_currentUser!.toJson()));
+ notifyListeners();
+
+ return true;
+ } else {
+ AppLogger.warning('Authentication test failed - user mismatch or null');
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ AppLogger.error('Authentication verification failed', e);
+
+ // Clear invalid auth data
+ await _clearStoredData();
+ _bearerToken = null;
+ _headers = null;
+ _currentUser = null;
+ notifyListeners();
+
+ return false;
+ }
+ }
}
\ No newline at end of file
diff --git a/lib/providers/spotify_provider.dart b/lib/providers/spotify_provider.dart
index 9efdb65..f6e7d62 100644
--- a/lib/providers/spotify_provider.dart
+++ b/lib/providers/spotify_provider.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../services/spotify_buddy_service.dart';
import '../services/spotify_service.dart';
import '../services/widget_service.dart';
+import '../services/app_logger.dart';
import '../models/track.dart';
import '../models/activity.dart';
import '../models/artist.dart';
@@ -48,9 +49,17 @@ class SpotifyProvider extends ChangeNotifier {
/// Handle authentication errors by setting flag for UI to handle
Future _handleAuthenticationError(String error) async {
- print('๐จ Authentication error detected in provider: $error');
+ AppLogger.error('Authentication error detected in provider', error);
_hasAuthError = true;
_authErrorMessage = error;
+
+ // Clear all cached data in this provider since authentication failed
+ _topTracks = [];
+ _topArtists = [];
+ _friendsActivities = [];
+ _currentlyPlaying = null;
+ _error = 'Authentication expired. Please login again.';
+
notifyListeners();
}
@@ -86,7 +95,7 @@ class SpotifyProvider extends ChangeNotifier {
// Update widget with new currently playing data
await _updateWidget();
} catch (e) {
- print('โ Failed to load currently playing: $e');
+ AppLogger.error('Failed to load currently playing', e);
_currentlyPlaying = null;
if (!e.toString().contains('No Bearer token available')) {
_error = e.toString();
@@ -167,7 +176,6 @@ class SpotifyProvider extends ChangeNotifier {
List activities = [];
// Get friend activities using Bearer token
- print('๐ Attempting to fetch friend activities with Bearer token...');
try {
// Use fast load when showing skeleton to avoid slow API calls
final useFastLoad = showSkeleton;
@@ -177,21 +185,15 @@ class SpotifyProvider extends ChangeNotifier {
// Update activities progressively as track durations are fetched
_friendsActivities = updatedActivities;
notifyListeners();
- print('๐ Progressive update: ${updatedActivities.length} activities updated');
},
);
- if (activities.isNotEmpty) {
- print('โ
Successfully fetched ${activities.length} friend activities');
- } else {
- print('โ ๏ธ No friend activities found');
- }
} catch (e) {
- print('โ Failed to fetch friend activities: $e');
+ AppLogger.error('Failed to fetch friend activities', e);
// Check if this is an authentication error
final errorMessage = e.toString();
if (_isAuthenticationError(errorMessage)) {
- print('๐ Authentication error detected, may need re-authentication');
+ AppLogger.auth('Authentication error detected, may need re-authentication');
_error = 'Authentication expired. Please login again.';
await _handleAuthenticationError(errorMessage);
} else {
@@ -310,7 +312,7 @@ class SpotifyProvider extends ChangeNotifier {
// Update widget after initial load
await _updateWidget();
} catch (e) {
- print('โ Error during fast initial load: $e');
+ AppLogger.error('Error during fast initial load', e);
_error = e.toString();
_isSkeletonLoading = false;
notifyListeners();
@@ -323,7 +325,7 @@ class SpotifyProvider extends ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 500));
try {
- print('๐ Enhancing activities with detailed info...');
+ AppLogger.debug('Enhancing activities with detailed info...');
// Load detailed activities (with duration checks)
final detailedActivities = await _buddyService.getFriendActivity(
fastLoad: false, // Full load with duration checks
@@ -331,7 +333,10 @@ class SpotifyProvider extends ChangeNotifier {
// Update activities progressively as track durations are fetched
_friendsActivities = updatedActivities;
notifyListeners();
- print('๐ Background enhancement update: ${updatedActivities.length} activities updated');
+ // Reduced logging frequency for background enhancements
+ if (updatedActivities.length <= 5 || updatedActivities.length % 10 == 0) {
+ AppLogger.debug('Background enhancement update: ${updatedActivities.length} activities updated');
+ }
},
);
@@ -339,15 +344,15 @@ class SpotifyProvider extends ChangeNotifier {
_friendsActivities = detailedActivities;
_lastUpdated = DateTime.now();
notifyListeners();
- print('โ
Enhanced ${detailedActivities.length} activities with detailed info');
+ AppLogger.debug('Enhanced ${detailedActivities.length} activities with detailed info');
}
} catch (e) {
- print('โ Failed to enhance activities: $e');
+ AppLogger.error('Failed to enhance activities', e);
// Check if this is an authentication error
final errorMessage = e.toString();
if (_isAuthenticationError(errorMessage)) {
- print('๐ Authentication error detected during enhancement');
+ AppLogger.auth('Authentication error detected during enhancement');
_error = 'Authentication expired. Please login again.';
await _handleAuthenticationError(errorMessage);
notifyListeners();
@@ -369,7 +374,7 @@ class SpotifyProvider extends ChangeNotifier {
friendsActivities: _friendsActivities,
);
} catch (e) {
- print('โ Failed to update widget: $e');
+ AppLogger.error('Failed to update widget', e);
}
}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index a3a3078..252939f 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -8,6 +8,8 @@ import '../widgets/activity_card.dart';
import '../widgets/activity_skeleton.dart';
import '../widgets/last_updated_indicator.dart';
import '../utils/auth_utils.dart';
+import '../services/app_logger.dart';
+
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -48,7 +50,7 @@ class _HomeScreenState extends State {
// Ensure authentication is initialized before loading data
if (!authProvider.isInitialized) {
- print('โ ๏ธ Authentication not yet initialized, skipping data load');
+ AppLogger.warning('Authentication not yet initialized, skipping data load');
return;
}
@@ -59,7 +61,7 @@ class _HomeScreenState extends State {
// Update widget with current user data
await spotifyProvider.updateWidget(currentUser: authProvider.currentUser);
} else {
- print('โ ๏ธ No authentication available - cannot load friend activities');
+ AppLogger.warning('No authentication available - cannot load friend activities');
}
}
@@ -69,7 +71,7 @@ class _HomeScreenState extends State {
// Ensure authentication is initialized before refreshing data
if (!authProvider.isInitialized) {
- print('โ ๏ธ Authentication not yet initialized, skipping data refresh');
+ AppLogger.warning('Authentication not yet initialized, skipping data refresh');
return;
}
@@ -80,51 +82,64 @@ class _HomeScreenState extends State {
// Update widget with current user data
await spotifyProvider.updateWidget(currentUser: authProvider.currentUser);
} else {
- print('โ ๏ธ No authentication available - cannot refresh friend activities');
+ AppLogger.warning('No authentication available - cannot refresh friend activities');
}
}
+
+
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
return Scaffold(
extendBodyBehindAppBar: true, // Extend body behind app bar
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: ClipRRect(
child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
- child: AppBar(
- title: const Text(
- 'Friends\' Activities',
- style: TextStyle(fontWeight: FontWeight.bold),
+ filter: ImageFilter.blur(sigmaX: isDark ? 20 : 10, sigmaY: isDark ? 20 : 10),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).appBarTheme.backgroundColor ?? theme.scaffoldBackgroundColor.withValues(alpha: 230),
+ boxShadow: [], // Empty box shadow
+ ),
+ child: AppBar(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ title: const Text(
+ 'Friends\' Activities',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
),
),
),
),
),
- body: Consumer2(
- builder: (context, authProvider, spotifyProvider, child) {
- return Column(
- children: [
-
- // Main content
- Expanded(
- child: _buildMainContent(spotifyProvider),
- ),
- ],
- );
- },
+ body: SafeArea(
+ child: Consumer2(
+ builder: (context, authProvider, spotifyProvider, child) {
+ return Column(
+ children: [
+
+ // Main content
+ Expanded(
+ child: _buildMainContent(spotifyProvider),
+ ),
+ ],
+ );
+ },
+ ),
),
);
}
-
Widget _buildMainContent(SpotifyProvider spotifyProvider) {
// Show skeleton loading for initial load
if (spotifyProvider.isSkeletonLoading) {
return Column(
children: [
- // Add proper spacing to account for the app bar
- SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight + 16),
+ const SizedBox(height: 16),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(
@@ -143,134 +158,105 @@ class _HomeScreenState extends State {
}
if (spotifyProvider.isLoading) {
- return Column(
- children: [
- SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight),
- const Expanded(
- child: Center(
- child: CircularProgressIndicator(),
- ),
- ),
- ],
+ return const Center(
+ child: CircularProgressIndicator(),
);
- }
-
- if (spotifyProvider.error != null) {
+ } if (spotifyProvider.error != null) {
final isAuthError = spotifyProvider.error!.contains('Authentication expired');
- return Column(
- children: [
- SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight),
- Expanded(
- child: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- isAuthError ? Icons.lock_outline : Icons.error_outline,
- size: 64,
- color: Colors.grey,
- ),
- const SizedBox(height: 16),
- Text(
- isAuthError ? 'Authentication Required' : 'Error loading activities',
- style: Theme.of(context).textTheme.headlineSmall,
- ),
- const SizedBox(height: 8),
- Text(
- spotifyProvider.error!,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Colors.grey,
- ),
- ),
- const SizedBox(height: 16),
- if (isAuthError) ...[
- ElevatedButton(
- onPressed: () async {
- final success = await AuthUtils.handleReAuthentication(context);
- if (success && mounted) {
- // Clear error and refresh data
- spotifyProvider.clearError();
- _loadData();
- }
- },
- child: const Text('Login Again'),
- ),
- const SizedBox(height: 8),
- TextButton(
- onPressed: () {
- spotifyProvider.clearError();
- _refreshData();
- },
- child: const Text('Retry'),
- ),
- ] else ...[
- ElevatedButton(
- onPressed: _refreshData,
- child: const Text('Retry'),
- ),
- ],
- ],
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ isAuthError ? Icons.lock_outline : Icons.error_outline,
+ size: 64,
+ color: Colors.grey,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ isAuthError ? 'Authentication Required' : 'Error loading activities',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ spotifyProvider.error!,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Colors.grey,
),
),
- ),
- ],
+ const SizedBox(height: 16),
+ if (isAuthError) ...[
+ ElevatedButton(
+ onPressed: () async {
+ final success = await AuthUtils.handleReAuthentication(context);
+ if (success && mounted) {
+ // Clear error and refresh data
+ spotifyProvider.clearError();
+ _loadData();
+ }
+ },
+ child: const Text('Login Again'),
+ ),
+ const SizedBox(height: 8),
+ TextButton(
+ onPressed: () {
+ spotifyProvider.clearError();
+ _refreshData();
+ },
+ child: const Text('Retry'),
+ ),
+ ] else ...[
+ ElevatedButton(
+ onPressed: _refreshData,
+ child: const Text('Retry'),
+ ),
+ ],
+ ],
+ ),
);
}
- final activities = spotifyProvider.friendsActivities;
-
- if (activities.isEmpty) {
- return Column(
- children: [
- SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight),
- Expanded(
- child: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Icon(
- Icons.music_note_outlined,
- size: 64,
- color: Colors.grey,
- ),
- const SizedBox(height: 16),
- Text(
- 'No recent activities',
- style: Theme.of(context).textTheme.headlineSmall,
- ),
- const SizedBox(height: 8),
- Text(
- 'Your friends haven\'t been listening to music recently',
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Colors.grey,
- ),
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: _refreshData,
- child: const Text('Refresh'),
- ),
- ],
+ final activities = spotifyProvider.friendsActivities; if (activities.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.music_note_outlined,
+ size: 64,
+ color: Colors.grey,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No recent activities',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Your friends haven\'t been listening to music recently',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Colors.grey,
),
),
- ),
- ],
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: _refreshData,
+ child: const Text('Refresh'),
+ ),
+ ],
+ ),
);
- }
-
- return RefreshIndicator(
+ } return RefreshIndicator(
onRefresh: _refreshData,
- edgeOffset: kToolbarHeight,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
- // Add proper spacing to account for the app bar
- SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight + 8),
+ const SizedBox(height: 8),
LastUpdatedIndicator(
lastUpdated: spotifyProvider.lastUpdated,
isRefreshing: spotifyProvider.isRefreshing,
@@ -279,10 +265,10 @@ class _HomeScreenState extends State {
),
),
SliverPadding(
- padding: EdgeInsets.only(
+ padding: const EdgeInsets.only(
left: 16,
right: 16,
- bottom: MediaQuery.of(context).padding.bottom,
+ bottom: 16,
),
sliver: SliverList.builder(
itemCount: activities.length,
diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart
index 0adb780..82ba21b 100644
--- a/lib/screens/login_screen.dart
+++ b/lib/screens/login_screen.dart
@@ -3,15 +3,30 @@ import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../utils/theme.dart';
import '../widgets/spotify_webview_login.dart';
+import '../services/update_service.dart';
+import '../services/app_logger.dart';
class LoginScreen extends StatelessWidget {
- const LoginScreen({super.key}); @override
+ const LoginScreen({super.key});
+
+ @override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
+ appBar: AppBar(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.system_update),
+ onPressed: () => _checkForUpdates(context),
+ tooltip: 'Check for Updates',
+ ),
+ ],
+ ),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
@@ -20,7 +35,7 @@ class LoginScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
- // Playtivity Logo
+ // Playtivity Logo
Container(
width: 120,
height: 120,
@@ -28,7 +43,7 @@ class LoginScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
- color: theme.primaryColor.withOpacity(0.25),
+ color: theme.primaryColor.withAlpha(64),
blurRadius: 15,
spreadRadius: 1,
offset: const Offset(0, 3),
@@ -58,7 +73,9 @@ class LoginScreen extends StatelessWidget {
),
),
- const SizedBox(height: 16), // Subtitle
+ const SizedBox(height: 16),
+
+ // Subtitle
Text(
'See what your friends are listening to',
textAlign: TextAlign.center,
@@ -75,7 +92,8 @@ class LoginScreen extends StatelessWidget {
Consumer(
builder: (context, authProvider, child) {
return ElevatedButton.icon(
- onPressed: authProvider.isLoading ? null : () => _handleLogin(context), icon: authProvider.isLoading
+ onPressed: authProvider.isLoading ? null : () => _handleLogin(context),
+ icon: authProvider.isLoading
? const SizedBox(
width: 20,
height: 20,
@@ -99,12 +117,13 @@ class LoginScreen extends StatelessWidget {
fontWeight: FontWeight.bold,
color: Colors.white,
),
- ), style: ElevatedButton.styleFrom(
+ ),
+ style: ElevatedButton.styleFrom(
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: isDark ? 0 : 3,
- shadowColor: isDark ? Colors.transparent : theme.primaryColor.withOpacity(0.3),
+ shadowColor: isDark ? Colors.transparent : theme.primaryColor.withAlpha(77),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
@@ -113,7 +132,9 @@ class LoginScreen extends StatelessWidget {
},
),
- const SizedBox(height: 16), // Info Text
+ const SizedBox(height: 16),
+
+ // Info Text
Text(
'You\'ll be redirected to Spotify to authorize this app',
textAlign: TextAlign.center,
@@ -123,7 +144,9 @@ class LoginScreen extends StatelessWidget {
),
),
- const Spacer(), // Footer
+ const Spacer(),
+
+ // Footer
Text(
'Made with โค๏ธ for music lovers',
textAlign: TextAlign.center,
@@ -145,20 +168,33 @@ class LoginScreen extends StatelessWidget {
try {
// Navigate to WebView login
- final result = await Navigator.of(context).push( MaterialPageRoute(
+ final result = await Navigator.of(context).push(
+ MaterialPageRoute(
builder: (context) => SpotifyWebViewLogin(
onAuthComplete: (bearerToken, headers) async {
- print('๐ Login screen received auth completion callback');
+ AppLogger.auth('Login screen received auth completion callback');
try {
+ // Process authentication without immediate navigation
await authProvider.handleAuthComplete(bearerToken, headers);
- print('โ
Authentication handling completed successfully');
+ AppLogger.auth('Authentication handling completed successfully');
+
+ // Add a small delay to ensure state is fully updated
+ await Future.delayed(const Duration(milliseconds: 200));
- // Ensure we're back on the login screen before popping
- if (context.mounted && Navigator.canPop(context)) {
- Navigator.of(context).pop(true);
+ // Verify authentication was successful before proceeding
+ if (authProvider.isAuthenticated) {
+ AppLogger.auth('Authentication verified - ready to proceed');
+
+ // Return success to the WebView (but don't navigate yet)
+ if (context.mounted && Navigator.canPop(context)) {
+ Navigator.of(context).pop(true);
+ }
+ } else {
+ AppLogger.auth('Authentication failed - auth provider not authenticated');
+ throw Exception('Authentication verification failed');
}
} catch (e) {
- print('โ Error in auth completion: $e');
+ AppLogger.auth('Error in auth completion', e);
if (context.mounted) {
// Pop the WebView first
if (Navigator.canPop(context)) {
@@ -182,29 +218,246 @@ class LoginScreen extends StatelessWidget {
),
),
);
- // If user cancelled or result is null, stop loading
- if (result != true) {
+
+ // Handle the result from the WebView
+ if (result == true) {
+ // Login was successful, wait a bit more to ensure all state is synced
+ await Future.delayed(const Duration(milliseconds: 300));
+
+ // Double-check authentication state before navigating
+ if (authProvider.isAuthenticated) {
+ AppLogger.auth('Login successful, navigating to home screen...');
+ if (context.mounted) {
+ // Use pushNamedAndRemoveUntil to ensure clean navigation stack
+ Navigator.of(context).pushNamedAndRemoveUntil(
+ '/',
+ (route) => false,
+ );
+ }
+ } else {
+ AppLogger.auth('Authentication lost after successful login - showing error');
+ authProvider.cancelLogin();
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Login failed: Authentication was not maintained'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ } else {
+ // Login was cancelled or failed
+ AppLogger.auth('Login was cancelled or failed');
authProvider.cancelLogin();
+ }
+ } catch (e) {
+ AppLogger.auth('Error in login flow', e);
+ authProvider.cancelLogin();
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Login failed: ${e.toString()}'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ // Handle the update check process
+ Future _checkForUpdates(BuildContext context) async {
+ // Show loading modal
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return const AlertDialog(
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Center(child: CircularProgressIndicator()),
+ SizedBox(height: 16),
+ Center(child: Text('Checking for updates...')),
+ ],
+ ),
+ );
+ },
+ );
+
+ try {
+ // Get current version info
+ final currentVersion = await UpdateService.getCurrentAppVersion();
+
+ // Check for updates
+ final updateResult = await UpdateService.checkForUpdates(forceCheck: true);
+
+ // Hide loading dialog
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+
+ if (updateResult.hasUpdate && updateResult.updateInfo != null) {
+ // Show update available dialog
+ if (!context.mounted) return;
+ final shouldUpdate = await _showUpdateDialog(
+ context,
+ updateResult.updateInfo!,
+ currentVersion,
+ );
+
+ if (shouldUpdate && context.mounted) {
+ // Start download with progress dialog
+ final downloadedFilePath = await UpdateService.showDownloadDialog(
+ context,
+ updateResult.updateInfo!,
+ );
+
+ if (downloadedFilePath != null && context.mounted) {
+ // Show installation dialog
+ final shouldInstall = await UpdateService.showInstallDialog(
+ context,
+ downloadedFilePath,
+ );
+
+ if (shouldInstall) {
+ // Installation started, show final message
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Installing update... The app will restart.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } else if (context.mounted) {
+ // Download was cancelled or failed
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Download cancelled or failed.'),
+ backgroundColor: Colors.orange,
+ ),
+ );
+ }
+ }
} else {
- // Login was successful, navigate to home
- print('โ
Login successful, navigating to home screen...');
+ // No update available
if (context.mounted) {
- Navigator.of(context).pushNamedAndRemoveUntil(
- '/',
- (route) => false,
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('You\'re running the latest version!'),
+ backgroundColor: Colors.green,
+ ),
);
}
}
} catch (e) {
- authProvider.cancelLogin();
+ // Hide loading dialog and show error
if (context.mounted) {
+ Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text('Login failed: ${e.toString()}'),
+ content: Text('Error checking for updates: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
+
+ // Show update dialog
+ Future _showUpdateDialog(
+ BuildContext context,
+ UpdateInfo updateInfo,
+ AppVersionInfo currentVersion,
+ ) async {
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ icon: Icon(
+ updateInfo.isNightly ? Icons.science : Icons.system_update,
+ color: updateInfo.isNightly ? Colors.orange : Colors.blue,
+ size: 32,
+ ),
+ title: Text(updateInfo.isNightly ? 'Nightly Update Available' : 'Update Available'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'A new ${updateInfo.isNightly ? 'nightly' : 'release'} version is available!',
+ ),
+ const SizedBox(height: 16),
+
+ // Version comparison
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Version Comparison',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Current: ${currentVersion.version}+${currentVersion.buildNumber}\n'
+ '${updateInfo.isNightly ? 'Latest Nightly' : 'Latest Release'}: ${updateInfo.version}+${updateInfo.buildNumber}',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ if (updateInfo.isNightly) ...[
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.orange.withAlpha(26),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.orange.withAlpha(77)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.warning, color: Colors.orange, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Nightly builds may contain bugs or incomplete features.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Colors.orange[800],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(false),
+ child: const Text('Later'),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.of(dialogContext).pop(true),
+ child: const Text('Update Now'),
+ ),
+ ],
+ );
+ },
+ ) ?? false;
+ }
}
\ No newline at end of file
diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart
index 80d8357..ffc72cf 100644
--- a/lib/screens/profile_screen.dart
+++ b/lib/screens/profile_screen.dart
@@ -8,7 +8,9 @@ import '../widgets/track_tile.dart';
import '../widgets/artist_tile.dart';
import '../widgets/currently_playing_card.dart';
import '../widgets/refresh_indicator_bar.dart';
+import '../utils/spotify_launcher.dart';
import 'settings_screen.dart';
+import '../services/app_logger.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@@ -42,7 +44,7 @@ class _ProfileScreenState extends State with TickerProviderStateM
// Ensure authentication is initialized before loading data
if (!authProvider.isInitialized) {
- print('โ ๏ธ Authentication not yet initialized, skipping data load');
+ AppLogger.warning('Authentication not yet initialized, skipping data load');
return;
}
@@ -51,97 +53,98 @@ class _ProfileScreenState extends State with TickerProviderStateM
final showLoading = spotifyProvider.topTracks.isEmpty && spotifyProvider.topArtists.isEmpty;
await spotifyProvider.refreshData(showLoading: showLoading);
} else {
- print('โ ๏ธ No authentication available - cannot load profile data');
- }
- }
-
- Future _refreshData() async {
- final authProvider = context.read();
- final spotifyProvider = context.read();
-
- // Ensure authentication is initialized before refreshing data
- if (!authProvider.isInitialized) {
- print('โ ๏ธ Authentication not yet initialized, skipping data refresh');
- return;
- }
-
- if (authProvider.isAuthenticated) {
- // Silent refresh - don't show loading spinner
- await spotifyProvider.silentRefresh();
- } else {
- print('โ ๏ธ No authentication available - cannot refresh profile data');
+ AppLogger.warning('No authentication available - cannot load profile data');
}
}
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
return Scaffold(
extendBodyBehindAppBar: true, // Extend body behind app bar
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: ClipRRect(
child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
- child: AppBar(
- title: const Text(
- 'Profile',
- style: TextStyle(fontWeight: FontWeight.bold),
+ filter: ImageFilter.blur(sigmaX: isDark ? 20 : 10, sigmaY: isDark ? 20 : 10),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).appBarTheme.backgroundColor ?? theme.scaffoldBackgroundColor.withValues(alpha: 230),
+ boxShadow: [], // Empty box shadow
),
- actions: [
- IconButton(
- icon: const Icon(Icons.settings),
- onPressed: () {
- Navigator.push(
- context,
- MaterialPageRoute(builder: (context) => const SettingsScreen()),
- );
- },
+ child: AppBar(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ title: const Text(
+ 'Profile',
+ style: TextStyle(fontWeight: FontWeight.bold),
),
- ],
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.settings),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const SettingsScreen()),
+ );
+ },
+ ),
+ ],
+ ),
),
),
),
- ),
- body: Consumer2(
- builder: (context, authProvider, spotifyProvider, child) {
- final user = authProvider.currentUser;
-
- return Column(
- children: [
- // Refresh indicator bar
- RefreshIndicatorBar(
- isRefreshing: spotifyProvider.isRefreshing,
- message: 'Updating profile data...',
- ),
- // User Profile Header
- Container(
- padding: EdgeInsets.only(
- left: 24,
- right: 24,
- top: MediaQuery.of(context).padding.top,
- bottom: 24,
+ ), body: SafeArea(
+ child: Consumer2(
+ builder: (context, authProvider, spotifyProvider, child) {
+ final user = authProvider.currentUser;
+
+ return Column(
+ children: [
+ // Refresh indicator bar
+ RefreshIndicatorBar(
+ isRefreshing: spotifyProvider.isRefreshing,
+ message: 'Updating profile data...',
),
+ // User Profile Header
+ Container(
+ padding: const EdgeInsets.only(
+ left: 24,
+ right: 24,
+ top: 24,
+ bottom: 24,
+ ),
child: Column(
children: [
// Profile Picture
- CircleAvatar(
- radius: 50,
- backgroundColor: Theme.of(context).primaryColor,
- backgroundImage: user?.imageUrl != null
- ? CachedNetworkImageProvider(user!.imageUrl!)
- : null,
- child: user?.imageUrl == null
- ? Text(
- user?.displayName.isNotEmpty == true
- ? user!.displayName[0].toUpperCase()
- : '?',
- style: const TextStyle(
- fontSize: 32,
- fontWeight: FontWeight.bold,
- color: Colors.white,
- ),
- )
- : null,
+ GestureDetector(
+ onTap: () async {
+ if (user != null) {
+ final spotifyUri = 'spotify:user:${user.id}';
+ await SpotifyLauncher.launchSpotifyUri(spotifyUri);
+ }
+ },
+ child: CircleAvatar(
+ radius: 50,
+ backgroundColor: Theme.of(context).primaryColor,
+ backgroundImage: user?.imageUrl != null
+ ? CachedNetworkImageProvider(user!.imageUrl!)
+ : null,
+ child: user?.imageUrl == null
+ ? Text(
+ user?.displayName.isNotEmpty == true
+ ? user!.displayName[0].toUpperCase()
+ : '?',
+ style: const TextStyle(
+ fontSize: 32,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ ),
+ )
+ : null,
+ ),
),
const SizedBox(height: 16),
@@ -199,10 +202,10 @@ class _ProfileScreenState extends State with TickerProviderStateM
_buildTopArtistsTab(spotifyProvider),
],
),
- ),
- ],
+ ), ],
);
},
+ ),
),
);
}
@@ -223,10 +226,8 @@ class _ProfileScreenState extends State with TickerProviderStateM
],
),
);
- }
-
- return ListView.builder(
- padding: EdgeInsets.only(top: 16, left: 16, right: 16, bottom: MediaQuery.of(context).padding.bottom),
+ } return ListView.builder(
+ padding: const EdgeInsets.only(top: 16, left: 16, right: 16, bottom: 16),
itemCount: spotifyProvider.topTracks.length,
itemBuilder: (context, index) {
final track = spotifyProvider.topTracks[index];
@@ -254,10 +255,8 @@ class _ProfileScreenState extends State with TickerProviderStateM
],
),
);
- }
-
- return ListView.builder(
- padding: EdgeInsets.only(top: 16, left: 16, right: 16, bottom: MediaQuery.of(context).padding.bottom),
+ } return ListView.builder(
+ padding: const EdgeInsets.only(top: 16, left: 16, right: 16, bottom: 16),
itemCount: spotifyProvider.topArtists.length,
itemBuilder: (context, index) {
final artist = spotifyProvider.topArtists[index];
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 4a34628..f1b3a0d 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -1,12 +1,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:package_info_plus/package_info_plus.dart';
import '../providers/auth_provider.dart';
import '../providers/theme_provider.dart';
import '../providers/spotify_provider.dart';
+import '../services/update_service.dart';
+import '../utils/version_utils.dart';
+import '../services/app_logger.dart';
-class SettingsScreen extends StatelessWidget {
+class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
+ @override
+ State createState() => _SettingsScreenState();
+}
+
+class _SettingsScreenState extends State {
+ bool? _isNightlyEnabled;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadNightlyPreference();
+ }
+
+ Future _loadNightlyPreference() async {
+ final isEnabled = await UpdateService.getNightlyBuildPreference();
+ if (mounted) {
+ setState(() {
+ _isNightlyEnabled = isEnabled;
+ });
+ }
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -16,133 +42,584 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(fontWeight: FontWeight.bold),
),
),
- body: Consumer2(
- builder: (context, themeProvider, authProvider, child) {
- return ListView(
- padding: const EdgeInsets.all(16),
- children: [
- // Appearance Section
- Card(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Text(
- 'Appearance',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
+ body: SafeArea(
+ child: Consumer2(
+ builder: (context, themeProvider, authProvider, child) {
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ // Appearance Section
+ Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ 'Appearance',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
),
),
- ),
- ListTile(
- leading: Icon(
- themeProvider.isDarkMode
- ? Icons.dark_mode
- : Icons.light_mode,
- ),
- title: const Text('Dark Mode'),
- subtitle: Text(
- themeProvider.isDarkMode ? 'Enabled' : 'Disabled',
- ),
- trailing: Switch(
- value: themeProvider.isDarkMode,
- onChanged: (value) {
- themeProvider.toggleTheme();
- },
+ ListTile(
+ leading: Icon(
+ themeProvider.isDarkMode
+ ? Icons.dark_mode
+ : Icons.light_mode,
+ ),
+ title: const Text('Dark Mode'),
+ subtitle: Text(
+ themeProvider.isDarkMode ? 'Enabled' : 'Disabled',
+ ),
+ trailing: Switch(
+ value: themeProvider.isDarkMode,
+ onChanged: (value) {
+ themeProvider.toggleTheme();
+ },
+ ),
),
- ),
- ],
+ ],
+ ),
),
- ),
-
- const SizedBox(height: 16),
-
- // Account Section
- Card(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Text(
- 'Account',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
+
+ const SizedBox(height: 16),
+
+ // Account Section
+ Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ 'Account',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
),
),
- ),
- if (authProvider.currentUser != null) ...[
- ListTile(
- leading: const Icon(Icons.person),
- title: const Text('Display Name'),
- subtitle: Text(authProvider.currentUser!.displayName),
- ),
+ if (authProvider.currentUser != null) ...[
+ ListTile(
+ leading: const Icon(Icons.person),
+ title: const Text('Display Name'),
+ subtitle: Text(authProvider.currentUser!.displayName),
+ ),
+ ListTile(
+ leading: const Icon(Icons.email),
+ title: const Text('Email'),
+ subtitle: Text(authProvider.currentUser!.email),
+ ),
+ ListTile(
+ leading: const Icon(Icons.public),
+ title: const Text('Country'),
+ subtitle: Text(authProvider.currentUser!.country),
+ ),
+ ],
ListTile(
- leading: const Icon(Icons.email),
- title: const Text('Email'),
- subtitle: Text(authProvider.currentUser!.email),
+ leading: const Icon(
+ Icons.logout,
+ color: Colors.red,
+ ),
+ title: const Text(
+ 'Logout',
+ style: TextStyle(color: Colors.red),
+ ),
+ subtitle: const Text('Sign out from your Spotify account'),
+ onTap: () => _showLogoutDialog(context),
),
- ListTile(
- leading: const Icon(Icons.public),
- title: const Text('Country'),
- subtitle: Text(authProvider.currentUser!.country),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 16),
+ // Updates Section
+ Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ 'Updates',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
),
+ _buildUpdatePreferencesTile(context),
+ _buildCheckForUpdatesTile(context),
],
- ListTile(
- leading: const Icon(
- Icons.logout,
- color: Colors.red,
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // About Section
+ Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ 'About',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
),
- title: const Text(
- 'Logout',
- style: TextStyle(color: Colors.red),
+ FutureBuilder(
+ future: PackageInfo.fromPlatform(),
+ builder: (context, snapshot) {
+ final version = snapshot.hasData
+ ? VersionUtils.formatVersion(snapshot.data!.version)
+ : 'Loading...';
+ return ListTile(
+ leading: Image.asset(
+ 'assets/images/playtivity_logo_small_icon.png',
+ width: 24,
+ height: 24,
+ ),
+ title: const Text('Playtivity'),
+ subtitle: Text('Version $version'),
+ );
+ },
),
- subtitle: const Text('Sign out from your Spotify account'),
- onTap: () => _showLogoutDialog(context),
- ),
- ],
+ const ListTile(
+ leading: Icon(Icons.info_outline),
+ title: Text('About'),
+ subtitle: Text('See what your friends are listening to on Spotify'),
+ ),
+ ],
+ ),
),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ // Build widget for update preferences
+ Widget _buildUpdatePreferencesTile(BuildContext context) {
+ // Show loading if preference not loaded yet
+ if (_isNightlyEnabled == null) {
+ return const ListTile(
+ leading: Icon(Icons.update),
+ title: Text('Nightly Builds'),
+ subtitle: Text('Get early access to new features (may be unstable)'),
+ trailing: CircularProgressIndicator(),
+ );
+ }
+
+ return ListTile(
+ leading: Icon(
+ _isNightlyEnabled! ? Icons.science : Icons.update,
+ color: _isNightlyEnabled! ? Colors.orange : null,
+ ),
+ title: const Text('Nightly Builds'),
+ subtitle: const Text(
+ 'Get early access to new features (may be unstable)'
+ ),
+ trailing: Switch(
+ value: _isNightlyEnabled!,
+ activeColor: Colors.orange,
+ onChanged: (value) async {
+ // Update local state immediately for instant UI feedback
+ setState(() {
+ _isNightlyEnabled = value;
+ });
+
+ // Save to preferences
+ await UpdateService.setNightlyBuildPreference(value);
+
+ // Show modal dialog instead of toast if nightly enabled
+ if (value && context.mounted) {
+ _showNightlyEnabledDialog(context);
+ }
+ },
+ ),
+ );
+ }
+
+ // Build widget for checking updates
+ Widget _buildCheckForUpdatesTile(BuildContext context) {
+ return ListTile(
+ leading: const Icon(Icons.system_update),
+ title: const Text('Check for Updates'),
+ subtitle: const Text('Look for new versions of the app'),
+ onTap: () => _checkForUpdates(context),
+ );
+ }
+
+ // Show modal dialog when nightly builds are enabled
+ void _showNightlyEnabledDialog(BuildContext context) {
+ showDialog(
+ context: context,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ icon: const Icon(
+ Icons.science,
+ color: Colors.orange,
+ size: 32,
+ ),
+ title: const Text('Nightly Builds Enabled'),
+ content: const Text(
+ 'You\'ve enabled nightly builds! You can now check for updates to get the latest development version with new features.\n\n'
+ 'Note: Nightly builds may be unstable and contain bugs.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('OK'),
+ ),
+ TextButton(
+ onPressed: () {
+ Navigator.of(dialogContext).pop();
+ _checkForUpdates(context);
+ },
+ child: const Text('Check Now'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ // Show custom update dialog with GitHub version information
+ Future _showCustomUpdateDialog(
+ BuildContext context,
+ UpdateInfo updateInfo,
+ AppVersionInfo currentVersion,
+ ) async {
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ icon: Icon(
+ updateInfo.isNightly ? Icons.science : Icons.system_update,
+ color: updateInfo.isNightly ? Colors.orange : Colors.blue,
+ size: 32,
+ ),
+ title: Text(updateInfo.isNightly ? 'Nightly Update Available' : 'Update Available'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'A new ${updateInfo.isNightly ? 'nightly' : 'release'} version is available from GitHub!',
),
-
const SizedBox(height: 16),
- // About Section
- Card(
+ // Version comparison container
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Text(
- 'About',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- ), ListTile(
- leading: Image.asset(
- 'assets/images/playtivity_logo_small_icon.png',
- width: 24,
- height: 24,
+ Text(
+ 'Version Comparison',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
),
- title: const Text('Playtivity'),
- subtitle: const Text('Version 0.0.1'),
),
- const ListTile(
- leading: Icon(Icons.info_outline),
- title: Text('About'),
- subtitle: Text('See what your friends are listening to on Spotify'),
+ const SizedBox(height: 8),
+ Text(
+ 'Current: ${currentVersion.version}+${currentVersion.buildNumber}\n'
+ '${updateInfo.isNightly ? 'Latest Nightly' : 'Latest Release'}: ${updateInfo.version}+${updateInfo.buildNumber}\n'
+ 'From: GitHub Repository\n'
+ 'Released: ${_formatDate(updateInfo.buildDate)}',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
),
],
),
),
+
+ // Show a brief summary instead of full changelog
+ if (updateInfo.changelog != null && updateInfo.changelog!.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.info_outline,
+ size: 16,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'This update includes bug fixes and improvements.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+
+ if (updateInfo.isNightly) ...[
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.warning, color: Colors.orange, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Nightly builds may contain bugs or incomplete features.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Colors.orange[800],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(false),
+ child: const Text('Later'),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.of(dialogContext).pop(true),
+ child: const Text('Update Now'),
+ ),
+ ],
+ );
+ },
+ ) ?? false;
+ }
+
+ // Handle the update check process with modal dialog
+ Future _checkForUpdates(BuildContext context) async {
+ // Store context.mounted in a local variable
+ final isContextMounted = context.mounted;
+
+ // Show loading modal
+ if (isContextMounted) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return const AlertDialog(
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Center(child: CircularProgressIndicator()),
+ SizedBox(height: 16),
+ Center(child: Text('Checking for updates...')),
+ ],
+ ),
);
},
- ),
+ );
+ }
+
+ try {
+ // Get current version info
+ final currentVersion = await UpdateService.getCurrentAppVersion();
+
+ // Check for updates
+ final updateResult = await UpdateService.checkForUpdates(forceCheck: true);
+
+ // Close loading dialog
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+
+ if (!context.mounted) return;
+
+ // Handle the update result with modal dialogs
+ if (updateResult.hasUpdate && updateResult.updateInfo != null) {
+ // Show our custom update dialog with GitHub version information
+ final shouldDownload = await _showCustomUpdateDialog(
+ context,
+ updateResult.updateInfo!,
+ currentVersion,
+ );
+
+ if (shouldDownload && context.mounted) {
+ // Show download dialog and get the downloaded file path
+ final filePath = await UpdateService.showDownloadDialog(
+ context,
+ updateResult.updateInfo!,
+ );
+
+ if (filePath != null && context.mounted) {
+ // Show installation dialog
+ await UpdateService.showInstallDialog(context, filePath);
+ }
+ }
+ } else if (updateResult.error != null) {
+ // Show error modal with version info
+ if (context.mounted) {
+ _showUpdateResultDialog(
+ context,
+ 'Update Check Failed',
+ 'Error: ${updateResult.error}',
+ Icons.error,
+ Colors.red,
+ currentVersion: currentVersion,
+ );
+ }
+ } else {
+ // No updates available modal - show current and latest version info
+ if (context.mounted) {
+ final isNightly = await UpdateService.getNightlyBuildPreference();
+ if (!context.mounted) return;
+
+ final latestInfo = isNightly
+ ? UpdateService.getLatestNightlyInfo()
+ : UpdateService.getLatestReleaseInfo();
+
+ _showUpdateResultDialog(
+ context,
+ 'No Updates Available',
+ 'You are already on the latest version!',
+ Icons.check_circle,
+ Colors.green,
+ currentVersion: currentVersion,
+ latestVersion: latestInfo,
+ );
+ }
+ }
+ } catch (e) {
+ // Close loading dialog if still open
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+
+ // Show error modal
+ if (context.mounted) {
+ // Get current version for error display
+ final currentVersion = await UpdateService.getCurrentAppVersion();
+ if (!context.mounted) return;
+
+ _showUpdateResultDialog(
+ context,
+ 'Update Check Failed',
+ 'An error occurred while checking for updates: $e',
+ Icons.error,
+ Colors.red,
+ currentVersion: currentVersion,
+ );
+ }
+ }
+ }
+
+ // Helper method to show update result modals
+ void _showUpdateResultDialog(
+ BuildContext context,
+ String title,
+ String message,
+ IconData icon,
+ Color iconColor, {
+ AppVersionInfo? currentVersion,
+ UpdateInfo? latestVersion,
+ }) {
+ // Build version information text
+ String versionInfo = '';
+ if (currentVersion != null) {
+ versionInfo += 'Current Version: ${currentVersion.version}+${currentVersion.buildNumber}\n';
+ }
+ if (latestVersion != null) {
+ final versionType = latestVersion.isNightly ? 'Latest Nightly' : 'Latest Release';
+ versionInfo += '$versionType: ${latestVersion.version}+${latestVersion.buildNumber}\n';
+ versionInfo += 'From: GitHub Repository\n';
+ versionInfo += 'Released: ${_formatDate(latestVersion.buildDate)}';
+ } else if (currentVersion != null) {
+ // If no latest version info available, show that we checked GitHub
+ versionInfo += 'Checked: GitHub Repository\n';
+ versionInfo += 'No newer version found';
+ }
+
+ showDialog(
+ context: context,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ icon: Icon(
+ icon,
+ color: iconColor,
+ size: 32,
+ ),
+ title: Text(title),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(message),
+ if (versionInfo.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Version Information',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ versionInfo,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('OK'),
+ ),
+ ],
+ );
+ },
);
- } void _showLogoutDialog(BuildContext context) {
+ }
+
+ // Helper method to format date
+ String _formatDate(DateTime date) {
+ return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} '
+ '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
+ }
+
+ void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
@@ -155,12 +632,15 @@ class SettingsScreen extends StatelessWidget {
child: const Text('Cancel'),
),
TextButton(
- onPressed: () async {
+ onPressed: () {
+ // Store context before async gap
+ final settingsContext = context;
+
// Close the confirmation dialog first
Navigator.of(dialogContext).pop();
- // Perform logout with the settings screen context, not dialog context
- await _performLogout(context);
+ // Perform logout with the stored context
+ _performLogout(settingsContext);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
@@ -171,9 +651,13 @@ class SettingsScreen extends StatelessWidget {
);
},
);
- } Future _performLogout(BuildContext context) async {
- print('๐ช Starting logout process...');
+ }
+
+ Future _performLogout(BuildContext context) async {
+ AppLogger.auth('Starting logout process...');
+ // Store route name at the start, before any async operations
+ final currentRoute = ModalRoute.of(context)?.settings.name;
final authProvider = context.read();
final spotifyProvider = context.read();
@@ -181,19 +665,19 @@ class SettingsScreen extends StatelessWidget {
await authProvider.logout();
spotifyProvider.clearData();
- print('๐ Checking navigation context...');
- print(' - context.mounted: ${context.mounted}');
- print(' - Current route: ${ModalRoute.of(context)?.settings.name}');
+ AppLogger.auth('Checking navigation context...');
+ AppLogger.auth(' - context.mounted: ${context.mounted}');
+ AppLogger.auth(' - Current route: $currentRoute');
// Check if still in valid context and not already on login screen
- if (context.mounted && ModalRoute.of(context)?.settings.name != '/login') {
- print('โ
Navigating to login screen...');
+ if (context.mounted && currentRoute != '/login') {
+ AppLogger.auth('Navigating to login screen...');
Navigator.of(context).pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
} else {
- print('โ ๏ธ Navigation skipped - context not valid or already on login');
+ AppLogger.auth('Navigation skipped - context not valid or already on login');
}
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/lib/services/app_logger.dart b/lib/services/app_logger.dart
new file mode 100644
index 0000000..3a9d25e
--- /dev/null
+++ b/lib/services/app_logger.dart
@@ -0,0 +1,79 @@
+import 'package:logger/logger.dart' show Logger, ProductionFilter, PrettyPrinter, ConsoleOutput, DateTimeFormat;
+
+/// Centralized logging service for the application
+/// Replaces print() statements with structured logging
+class AppLogger {
+ static Logger? _logger;
+
+ /// Initialize the logger with appropriate configuration
+ static Logger get logger {
+ _logger ??= Logger(
+ filter: ProductionFilter(), // Only show errors/warnings in production
+ printer: PrettyPrinter(
+ methodCount: 2, // Number of method calls to be displayed
+ errorMethodCount: 8, // Number of method calls if error/warning occurred
+ lineLength: 120, // Width of the output
+ colors: true, // Colorful log messages
+ printEmojis: true, // Print emoji for each log level
+ dateTimeFormat: DateTimeFormat.none, // Format for timestamps in log messages
+ ),
+ output: ConsoleOutput(),
+ );
+ return _logger!;
+ }
+
+ /// Debug level logging - for detailed information during development
+ static void debug(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.d(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// Info level logging - for general information
+ static void info(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.i(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// Warning level logging - for potentially harmful situations
+ static void warning(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.w(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// Error level logging - for error events
+ static void error(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.e(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// Fatal level logging - for very severe error events
+ static void fatal(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.f(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// Verbose level logging - for extremely detailed information
+ static void verbose(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.t(message, error: error, stackTrace: stackTrace);
+ }
+
+ /// HTTP request/response logging
+ static void http(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.d('๐ HTTP: $message', error: error, stackTrace: stackTrace);
+ }
+
+ /// Authentication related logging
+ static void auth(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.i('๐ AUTH: $message', error: error, stackTrace: stackTrace);
+ }
+
+ /// Widget/UI related logging
+ static void widget(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.d('๐ฑ WIDGET: $message', error: error, stackTrace: stackTrace);
+ }
+
+ /// Background service logging
+ static void background(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.i('๐ BACKGROUND: $message', error: error, stackTrace: stackTrace);
+ }
+
+ /// Spotify API related logging
+ static void spotify(String message, [dynamic error, StackTrace? stackTrace]) {
+ logger.d('๐ต SPOTIFY: $message', error: error, stackTrace: stackTrace);
+ }
+}
diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart
index 900846b..4403ed4 100644
--- a/lib/services/background_service.dart
+++ b/lib/services/background_service.dart
@@ -2,10 +2,9 @@ import 'package:workmanager/workmanager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'widget_service.dart';
-import '../services/spotify_service.dart';
import '../services/spotify_buddy_service.dart';
import '../models/user.dart';
-import '../models/activity.dart';
+import 'app_logger.dart';
class BackgroundService {
static const String _widgetUpdateTaskName = "widget_update_task";
@@ -15,7 +14,7 @@ class BackgroundService {
static Future initialize() async {
await Workmanager().initialize(
callbackDispatcher,
- isInDebugMode: true, // Set to false in production
+ isInDebugMode: false, // Set to false in production
);
}
@@ -38,40 +37,36 @@ class BackgroundService {
'task_type': 'widget_update',
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
- );
-
- print('๐ Background widget update task registered');
+ );
+ AppLogger.background('Background widget update task registered');
} catch (e) {
- print('โ Error registering background task: $e');
+ AppLogger.error('Error registering background task', e);
}
}
// Cancel widget update task
static Future cancelWidgetUpdateTask() async {
- try {
- await Workmanager().cancelByUniqueName(_widgetUpdateTaskId);
- print('๐ Background widget update task cancelled');
+ try { await Workmanager().cancelByUniqueName(_widgetUpdateTaskId);
+ AppLogger.background('Background widget update task cancelled');
} catch (e) {
- print('โ Error cancelling background task: $e');
+ AppLogger.error('Error cancelling background task', e);
}
}
// Cancel all background tasks
static Future cancelAllTasks() async {
- try {
- await Workmanager().cancelAll();
- print('๐ All background tasks cancelled');
+ try { await Workmanager().cancelAll();
+ AppLogger.background('All background tasks cancelled');
} catch (e) {
- print('โ Error cancelling all background tasks: $e');
+ AppLogger.error('Error cancelling all background tasks', e);
}
}
}
// Background callback dispatcher - must be top-level function
@pragma('vm:entry-point')
-void callbackDispatcher() {
- Workmanager().executeTask((task, inputData) async {
- print('๐ Background task started: $task');
+void callbackDispatcher() { Workmanager().executeTask((task, inputData) async {
+ AppLogger.background('Background task started: $task');
try {
switch (task) {
@@ -79,22 +74,21 @@ void callbackDispatcher() {
await _updateWidgetInBackground(inputData);
break;
default:
- print('โ Unknown background task: $task');
+ AppLogger.warning('Unknown background task: $task');
}
- print('โ
Background task completed: $task');
+ AppLogger.background('Background task completed: $task');
return Future.value(true);
} catch (e) {
- print('โ Background task failed: $task - $e');
+ AppLogger.error('Background task failed: $task', e);
return Future.value(false);
}
});
}
// Update widget in background
-Future _updateWidgetInBackground(Map? inputData) async {
- try {
- print('๐ฑ Starting background widget update...');
+Future _updateWidgetInBackground(Map? inputData) async { try {
+ AppLogger.background('Starting background widget update...');
// Get stored authentication data
final prefs = await SharedPreferences.getInstance();
@@ -102,7 +96,7 @@ Future _updateWidgetInBackground(Map? inputData) async {
final bearerToken = prefs.getString('flutter.spotify_bearer_token');
if (userJson == null || bearerToken == null) {
- print('โ No authentication data found for background update');
+ AppLogger.warning('No authentication data found for background update');
return;
}
@@ -115,9 +109,8 @@ Future _updateWidgetInBackground(Map? inputData) async {
// Fetch friends' activities
final friendsActivities = await buddyService.getFriendActivity();
-
- if (friendsActivities.isNotEmpty) {
- print('๐ Background: Fetched ${friendsActivities.length} activities');
+ if (friendsActivities.isNotEmpty) {
+ AppLogger.background('Fetched ${friendsActivities.length} activities');
// Update widget with new data
await WidgetService.updateWidget(
@@ -125,9 +118,9 @@ Future _updateWidgetInBackground(Map? inputData) async {
friendsActivities: friendsActivities,
);
- print('โ
Background widget update completed');
+ AppLogger.background('Background widget update completed');
} else {
- print('๐ Background: No activities found');
+ AppLogger.background('No activities found');
// Update widget with empty data
await WidgetService.updateWidget(
@@ -135,9 +128,8 @@ Future _updateWidgetInBackground(Map? inputData) async {
friendsActivities: [],
);
}
-
- } catch (e) {
- print('โ Error in background widget update: $e');
+ } catch (e) {
+ AppLogger.error('Error in background widget update', e);
rethrow;
}
}
\ No newline at end of file
diff --git a/lib/services/http_interceptor.dart b/lib/services/http_interceptor.dart
index ad30acc..a5982c6 100644
--- a/lib/services/http_interceptor.dart
+++ b/lib/services/http_interceptor.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../utils/auth_utils.dart';
+import 'app_logger.dart';
/// HTTP interceptor that automatically handles 401/403 errors by redirecting to login
class HttpInterceptor {
@@ -17,6 +18,13 @@ class HttpInterceptor {
static void clearContext() {
_currentContext = null;
}
+
+ /// Check if the URL should be excluded from automatic login redirection
+ static bool _shouldExcludeFromLoginRedirect(Uri url) {
+ // Exclude the top content endpoint from automatic login redirection
+ // as it handles its own 401 retry logic
+ return url.toString().contains('api-partner.spotify.com/pathfinder/v2/query');
+ }
/// Intercepted GET request that handles 401/403 errors
static Future get(
@@ -25,7 +33,7 @@ class HttpInterceptor {
}) async {
try {
final response = await http.get(url, headers: headers);
- await _handleResponse(response);
+ await _handleResponse(response, url);
return response;
} catch (e) {
rethrow;
@@ -46,24 +54,40 @@ class HttpInterceptor {
body: body,
encoding: encoding,
);
- await _handleResponse(response);
+ await _handleResponse(response, url);
return response;
} catch (e) {
rethrow;
}
}
-
+
/// Handle response and check for authentication errors
- static Future _handleResponse(http.Response response) async {
- if ((response.statusCode == 401 || response.statusCode == 403) && _currentContext != null) {
- print('๐จ HTTP ${response.statusCode} detected - redirecting to login');
+ static Future _handleResponse(http.Response response, Uri url) async {
+ if (response.statusCode == 401 || response.statusCode == 403) {
+ AppLogger.http('HTTP ${response.statusCode} detected - authentication error');
+
+ // Skip login redirection for excluded endpoints
+ if (_shouldExcludeFromLoginRedirect(url)) {
+ AppLogger.http('Skipping login redirect for excluded endpoint: ${url.path}');
+ return;
+ }
- // Use the AuthUtils to handle the authentication error
- // This will log out the user and navigate to login screen
- await AuthUtils.handleAuthenticationError(
- _currentContext!,
- errorMessage: 'Session expired (${response.statusCode})',
- );
+ if (_currentContext != null && _currentContext!.mounted) {
+ AppLogger.http('Context available - redirecting to login');
+
+ // Use the AuthUtils to handle the authentication error
+ // This will log out the user and navigate to login screen
+ await AuthUtils.handleAuthenticationError(
+ _currentContext!,
+ errorMessage: 'Session expired (${response.statusCode})',
+ );
+ } else {
+ AppLogger.http('No context available - throwing authentication error');
+
+ // No context available, just throw an error that can be caught
+ // by the calling code and handled appropriately
+ throw Exception('Authentication failed: HTTP ${response.statusCode}');
+ }
}
}
}
diff --git a/lib/services/spotify_buddy_service.dart b/lib/services/spotify_buddy_service.dart
index 56fe63c..bdae7cb 100644
--- a/lib/services/spotify_buddy_service.dart
+++ b/lib/services/spotify_buddy_service.dart
@@ -8,6 +8,7 @@ import 'package:playtivity/models/track.dart';
import 'package:playtivity/models/playlist.dart';
import 'package:playtivity/models/artist.dart';
import 'http_interceptor.dart';
+import 'app_logger.dart';
// import 'package:playtivity/services/spotify_service.dart';
class SpotifyBuddyService {
@@ -26,11 +27,14 @@ class SpotifyBuddyService {
// Public factory constructor that returns the singleton
factory SpotifyBuddyService() => instance;
- // Cache for complete cookie string to avoid repeated requests
+ // Cache for complete cookie string
String? _completeCookieString;
// New: Direct Bearer token support (bypasses TOTP generation)
String? _directBearerToken;
+
+ // New: Client token storage
+ String? _clientToken;
// Spotify service for fetching track details
// final SpotifyService _spotifyService = SpotifyService();
@@ -68,14 +72,13 @@ class SpotifyBuddyService {
if (cacheJson != null) {
final cacheData = json.decode(cacheJson) as Map;
- _trackDurationCache.clear();
- cacheData.forEach((key, value) {
- _trackDurationCache[key] = value as int;
- });
- print('๐ฆ Loaded ${_trackDurationCache.length} cached track durations');
+ _trackDurationCache.clear();
+ cacheData.forEach((key, value) {
+ _trackDurationCache[key] = value as int;
+ });
}
} catch (e) {
- print('โ Error loading track duration cache: $e');
+ AppLogger.spotify('โ Error loading track duration cache: $e');
}
}
@@ -85,9 +88,9 @@ class SpotifyBuddyService {
final prefs = await SharedPreferences.getInstance();
final cacheJson = json.encode(_trackDurationCache);
await prefs.setString(_trackDurationCacheKey, cacheJson);
- print('๐พ Saved ${_trackDurationCache.length} track durations to cache');
+ AppLogger.spotify('๐พ Saved ${_trackDurationCache.length} track durations to cache');
} catch (e) {
- print('โ Error saving track duration cache: $e');
+ AppLogger.spotify('โ Error saving track duration cache: $e');
}
}
@@ -98,9 +101,9 @@ class SpotifyBuddyService {
_cacheModified = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_trackDurationCacheKey);
- print('๐๏ธ Cleared track duration cache');
+ AppLogger.spotify('๐๏ธ Cleared track duration cache');
} catch (e) {
- print('โ Error clearing track duration cache: $e');
+ AppLogger.spotify('โ Error clearing track duration cache: $e');
}
}
@@ -108,7 +111,7 @@ class SpotifyBuddyService {
void clearBuddyListCache() {
_cachedBuddyActivities = null;
_lastBuddyListFetch = null;
- print('๐๏ธ Cleared buddy list cache - next request will fetch fresh data');
+ AppLogger.spotify('๐๏ธ Cleared buddy list cache - next request will fetch fresh data');
}
/// Gets information about the current buddy list cache status
@@ -146,7 +149,7 @@ class SpotifyBuddyService {
// Check cache first
if (_trackDurationCache.containsKey(trackUri)) {
- print('๐พ Using cached duration for track: $trackUri');
+ AppLogger.spotify('๐พ Using cached duration for track: $trackUri');
return _trackDurationCache[trackUri];
}
@@ -174,7 +177,7 @@ class SpotifyBuddyService {
// Cache the duration in memory
_trackDurationCache[trackUri] = durationMs;
_cacheModified = true;
- print('โ
Fetched and cached duration for track $trackId: ${(durationMs/1000).toStringAsFixed(1)}s');
+
return durationMs;
}
} else {
@@ -189,40 +192,38 @@ class SpotifyBuddyService {
/// Sets the Bearer token and headers directly (bypasses TOTP generation)
void setBearerToken(String bearerToken, Map headers) {
- print('๐ง setBearerToken called with:');
- print(' - bearerToken: ${bearerToken.substring(0, 20)}... (length: ${bearerToken.length})');
- print(' - headers keys: ${headers.keys.join(', ')}');
+ AppLogger.spotify('๐ง setBearerToken called with:');
+ AppLogger.spotify(' - bearerToken: ${bearerToken.substring(0, 20)}... (length: ${bearerToken.length})');
+ AppLogger.spotify(' - headers keys: ${headers.keys.join(', ')}');
_directBearerToken = bearerToken;
// Store the complete cookie string
_completeCookieString = headers['Cookie'] ?? '';
- print('โ
Bearer token and headers set directly');
- print(' Token length: ${bearerToken.length}');
- print(' Headers: ${headers.keys.join(', ')}');
- print(' Cookie key exists: ${headers.containsKey('Cookie')}');
- print(' Cookie value: ${headers['Cookie']?.substring(0, 100) ?? 'null'}...');
- print(' Cookie length: ${_completeCookieString?.length ?? 0}');
- print(' Final _completeCookieString: ${_completeCookieString?.isNotEmpty == true ? '${_completeCookieString!.substring(0, 100)}...' : 'EMPTY'}');
- print(' _directBearerToken stored: ${_directBearerToken?.substring(0, 20)}...');
+ // Store the client token if present
+ if (headers['client-token'] != null) {
+ _clientToken = headers['client-token'];
+ AppLogger.spotify('โ
Client token stored (length: ${_clientToken?.length ?? 0})');
+ }
+
+ AppLogger.spotify('โ
Bearer token and headers set directly');
+ AppLogger.spotify(' Token length: ${bearerToken.length}');
+ AppLogger.spotify(' Headers: ${headers.keys.join(', ')}');
+ AppLogger.spotify(' Cookie key exists: ${headers.containsKey('Cookie')}');
+ AppLogger.spotify(' Cookie value: ${headers['Cookie']?.substring(0, 100) ?? 'null'}...');
+ AppLogger.spotify(' Cookie length: ${_completeCookieString?.length ?? 0}');
+ AppLogger.spotify(' Final _completeCookieString: ${_completeCookieString?.isNotEmpty == true ? '${_completeCookieString!.substring(0, 100)}...' : 'EMPTY'}');
+ AppLogger.spotify('_directBearerToken stored: ${_directBearerToken?.substring(0, 20)}...');
+ AppLogger.spotify('_clientToken stored: ${_clientToken != null ? '${_clientToken!.substring(0, 20)}...' : 'null'}');
}
/// Gets the current Bearer token (direct only)
String? getBearerToken() {
- print('๐ getBearerToken called:');
- print(' - _directBearerToken null: ${_directBearerToken == null}');
- print(' - _directBearerToken empty: ${_directBearerToken?.isEmpty ?? true}');
- print(' - _directBearerToken length: ${_directBearerToken?.length ?? 0}');
- print(' - _directBearerToken preview: ${_directBearerToken?.substring(0, 20) ?? 'null'}...');
-
if (_directBearerToken != null && _directBearerToken!.isNotEmpty) {
- print('โ
Using direct Bearer token');
return _directBearerToken;
}
-
- print('โ No Bearer token available');
- throw Exception('No Bearer token available - must authenticate first');
+ return null; // Return null instead of throwing exception
}
/// Gets the complete cookie string
@@ -230,11 +231,33 @@ class SpotifyBuddyService {
return _completeCookieString;
}
+ /// Gets the client token
+ String? getClientToken() {
+ if (_clientToken != null && _clientToken!.isNotEmpty) {
+ AppLogger.spotify('โ
Using stored client token');
+ return _clientToken;
+ }
+ AppLogger.spotify('โ No client token available');
+ return null;
+ }
+
/// Clears the stored Bearer token and headers
void clearBearerToken() {
+ AppLogger.spotify('๐๏ธ Clearing all SpotifyBuddyService state...');
+
+ // Clear authentication tokens
_directBearerToken = null;
_completeCookieString = null;
- print('๐๏ธ Cleared Bearer token and headers');
+ _clientToken = null;
+
+ // Clear all cached data to prevent stale state
+ clearBuddyListCache();
+ _trackDurationCache.clear();
+ _artistDetailsCache.clear();
+ _cacheModified = false;
+ _artistCacheModified = false;
+
+ AppLogger.spotify('โ
Cleared Bearer token, headers, and all cached data');
}
@@ -247,15 +270,15 @@ class SpotifyBuddyService {
final userName = friend['user']?['display_name'] ?? 'Unknown';
if (timestamp == null || track == null) {
- print('โ $userName: Missing timestamp or track data');
+ AppLogger.spotify('โ $userName: Missing timestamp or track data');
return false;
}
// Get song duration in milliseconds - use provided duration or try to get from track data
final trackDurationMs = durationMs ?? track['duration_ms'];
if (trackDurationMs == null) {
- print('โ $userName: Missing duration_ms in track data and no duration provided');
- print('๐ Track data: $track');
+ AppLogger.spotify('โ $userName: Missing duration_ms in track data and no duration provided');
+ AppLogger.spotify('๐ Track data: $track');
return false;
}
@@ -266,20 +289,13 @@ class SpotifyBuddyService {
// Calculate elapsed time since the friend started playing
final elapsedMs = currentTime.difference(friendTimestamp).inMilliseconds;
- print('๐ $userName: timestamp=$timestamp, friendTime=${friendTimestamp.toIso8601String()}, now=${currentTime.toIso8601String()}');
-
// Friend is currently playing if elapsed time is less than song duration
// Add a small buffer (5 seconds) to account for network delays
final isPlaying = elapsedMs >= 0 && elapsedMs < (trackDurationMs + 5000);
- print('โฑ๏ธ ${friend['user']?['display_name'] ?? 'Unknown'}: '
- 'elapsed=${(elapsedMs/1000).toStringAsFixed(1)}s, '
- 'duration=${(trackDurationMs/1000).toStringAsFixed(1)}s, '
- 'playing=$isPlaying');
-
return isPlaying;
} catch (e) {
- print('โ Error calculating playback status: $e');
+ AppLogger.spotify('โ Error calculating playback status: $e');
return false;
}
}
@@ -290,14 +306,14 @@ class SpotifyBuddyService {
// If no cache exists or cache is older than 1.5 minutes, refresh
if (_cachedBuddyActivities == null || _lastBuddyListFetch == null) {
- print('๐ Cache refresh needed: No cached data');
+ AppLogger.spotify('๐ Cache refresh needed: No cached data');
return true;
}
// Check if cache has expired (1.5 minutes)
final cacheAge = now.difference(_lastBuddyListFetch!);
if (cacheAge >= _buddyListCacheDuration) {
- print('๐ Cache refresh needed: Cache expired (${cacheAge.inSeconds}s > ${_buddyListCacheDuration.inSeconds}s)');
+ AppLogger.spotify('๐ Cache refresh needed: Cache expired (${cacheAge.inSeconds}s > ${_buddyListCacheDuration.inSeconds}s)');
return true;
}
@@ -313,42 +329,48 @@ class SpotifyBuddyService {
// If track should have finished (with 5 second buffer), refresh cache
if (elapsedMs >= (trackDurationMs + 5000)) {
- print('๐ Cache refresh needed: Track "${activity.track!.name}" by ${activity.user.displayName} should have finished');
+ AppLogger.spotify('๐ Cache refresh needed: Track "${activity.track!.name}" by ${activity.user.displayName} should have finished');
return true;
}
}
}
- print('๐ Cache still valid: Age=${cacheAge.inSeconds}s, no tracks finished');
+ AppLogger.spotify('๐ Cache still valid: Age=${cacheAge.inSeconds}s, no tracks finished');
return false;
}
/// Generates a curl command for debugging API requests
- // String _generateCurlCommand(String method, String url, Map headers, {String? body}) {
- // final buffer = StringBuffer();
- // buffer.write('curl -X $method');
- //
- // // Add headers
- // headers.forEach((key, value) {
- // buffer.write(' -H "$key: $value"');
- // });
- //
- // // Add body if present
- // if (body != null && body.isNotEmpty) {
- // buffer.write(' -d \'$body\'');
- // });
- //
- // // Add URL
- // buffer.write(' "$url"');
- //
- // return buffer.toString();
- // }
+ String _generateCurlCommand(String method, String url, Map headers, {String? body}) {
+ final buffer = StringBuffer();
+ buffer.write("curl '$url'");
+
+ // Add headers
+ headers.forEach((key, value) {
+ // Escape single quotes in values
+ final escapedValue = value.replaceAll("'", "'\\''");
+ buffer.write(" \\\n -H '$key: $escapedValue'");
+ });
+
+ // Add method if not GET
+ if (method != 'GET') {
+ buffer.write(" \\\n -X $method");
+ }
+
+ // Add body if present
+ if (body != null && body.isNotEmpty) {
+ // Escape single quotes in body
+ final escapedBody = body.replaceAll("'", "'\\''");
+ buffer.write(" \\\n --data-raw '$escapedBody'");
+ }
+
+ return buffer.toString();
+ }
Future> getFriendActivity({
bool fastLoad = false,
Function(List)? onActivitiesUpdate,
}) async {
- print('๐ getFriendActivity called');
+ AppLogger.spotify('๐ getFriendActivity called');
// The new buddylist endpoint only requires Bearer token authentication
String? accessToken = getBearerToken();
@@ -357,7 +379,7 @@ class SpotifyBuddyService {
}
// Check if we can use cached data
if (!_shouldRefreshBuddyList()) {
- print('โ
Using cached buddy list data');
+ AppLogger.spotify('โ
Using cached buddy list data');
return _cachedBuddyActivities!;
}
@@ -368,7 +390,7 @@ class SpotifyBuddyService {
await _loadTrackDurationCache();
}
- print('โ
Got access token, fetching friend activity...');
+ AppLogger.spotify('โ
Got access token, fetching friend activity...');
// Use the new buddylist endpoint - no hash parameter needed
final url = '$_baseUrl/presence-view/v1/buddylist';
@@ -382,12 +404,9 @@ class SpotifyBuddyService {
headers: headers,
);
- print('๐ก Buddy list API response: ${response.statusCode}');
- print('๐ฆ Response body: ${response.body}');
-
+ // Only log status code for debugging
// Handle unauthorized response - clear cache and retry once
if (response.statusCode == 401 || response.statusCode == 403) {
- print('๐ Access token unauthorized, clearing cache and retrying...');
_completeCookieString = null;
// Use the new buddylist endpoint for retry as well
@@ -402,8 +421,6 @@ class SpotifyBuddyService {
headers: retryHeaders,
);
- print('๐ก Retry response: ${retryResponse.statusCode}');
-
if (retryResponse.statusCode == 200) {
final activities = await _parseActivityResponse(
retryResponse.body,
@@ -415,11 +432,9 @@ class SpotifyBuddyService {
_lastBuddyListFetch = DateTime.now();
return activities;
} else {
- print('โ Retry also failed with status: ${retryResponse.statusCode}');
throw Exception('Failed to fetch friend activity: ${retryResponse.statusCode}');
}
} catch (retryError) {
- print('โ Error during retry request: $retryError');
throw Exception('Network error during retry: $retryError');
}
}
@@ -433,14 +448,11 @@ class SpotifyBuddyService {
// Cache the successful response
_cachedBuddyActivities = activities;
_lastBuddyListFetch = DateTime.now();
- print('๐พ Cached buddy list data with ${activities.length} activities');
return activities;
} else {
- print('โ Failed to fetch friend activity: ${response.statusCode} - ${response.body}');
- throw Exception('Failed to fetch friend activity: ${response.statusCode} - ${response.body}');
+ throw Exception('Failed to fetch friend activity: ${response.statusCode}');
}
} catch (e) {
- print('โ Error making buddy list API request: $e');
return [];
}
},
@@ -455,11 +467,9 @@ class SpotifyBuddyService {
}) async {
try {
final data = json.decode(responseBody);
- print('๐ฆ Buddy list response data: $data');
final friends = data['friends'] as List?;
if (friends != null) {
- print('๐ฅ Found ${friends.length} friends in response');
final activities = [];
final tracksNeedingDuration = [];
@@ -469,10 +479,7 @@ class SpotifyBuddyService {
final userInfo = friend['user'];
final timestamp = friend['timestamp'] ?? DateTime.now().millisecondsSinceEpoch;
- print('๐ค Processing friend: ${userInfo?['name']} at timestamp $timestamp');
-
// Create User object
- // Extract user ID from URI (e.g., "spotify:user:214diupj3zah2rimagmv4wrgy" -> "214diupj3zah2rimagmv4wrgy")
final userUri = userInfo['uri'] ?? '';
final userId = userUri.startsWith('spotify:user:')
? userUri.substring('spotify:user:'.length)
@@ -490,10 +497,8 @@ class SpotifyBuddyService {
// Check if this is a track or playlist activity
final trackInfo = friend['track'];
final playlistInfo = friend['playlist'];
- // final contextInfo = friend['context']; // v2 API includes context - not used yet
if (playlistInfo != null) {
- print('๐ต Processing playlist activity: ${playlistInfo['name']}');
// For playlists, we can't calculate duration-based playback
final playlist = Playlist(
id: playlistInfo['uri']?.split(':').last ?? '',
@@ -515,8 +520,6 @@ class SpotifyBuddyService {
type: ActivityType.playlist,
));
} else if (trackInfo != null) {
- print('๐ต Processing track activity: ${trackInfo['name']} by ${trackInfo['artist']?['name']}');
-
// Get track duration from cache or API response
int? durationMs = trackInfo['duration_ms'];
final trackUri = trackInfo['uri'] ?? '';
@@ -525,20 +528,14 @@ class SpotifyBuddyService {
// Check cache first for duration
if (durationMs == null && trackUri.isNotEmpty && _trackDurationCache.containsKey(trackUri)) {
durationMs = _trackDurationCache[trackUri];
- print('๐พ Using cached duration for track: $trackUri');
}
// If we have duration, calculate if currently playing
if (durationMs != null) {
isCurrentlyPlaying = _isCurrentlyPlaying(friend, durationMs: durationMs);
- print('๐ต Friend activity: ${userInfo['name']} - Currently Playing: $isCurrentlyPlaying');
} else if (!fastLoad && trackUri.isNotEmpty) {
// Mark this track as needing duration fetch
tracksNeedingDuration.add(i);
- print('โณ Track needs duration fetch: $trackUri');
- } else {
- // In fast load mode or no URI, assume not currently playing
- print('โก Fast load or no URI: ${userInfo['name']} - Skipping duration check');
}
// Create Track object - handle v2 API structure
@@ -563,16 +560,12 @@ class SpotifyBuddyService {
isCurrentlyPlaying: isCurrentlyPlaying,
type: ActivityType.track,
));
- } else {
- print('โ ๏ธ Friend ${userInfo?['name']} has no track or playlist data');
}
}
// Sort by timestamp - most recent first
activities.sort((a, b) => b.timestamp.compareTo(a.timestamp));
- print('โ
Parsed ${activities.length} activities successfully (${tracksNeedingDuration.length} tracks need duration fetch)');
-
// Second pass: Fetch missing track durations in background and update progressively
if (tracksNeedingDuration.isNotEmpty && !fastLoad) {
_fetchTrackDurationsProgressively(friends, activities, tracksNeedingDuration, onActivitiesUpdate);
@@ -585,12 +578,9 @@ class SpotifyBuddyService {
}
return activities;
- } else {
- print('โ ๏ธ No friends array found in response');
}
} catch (e) {
- print('โ Error parsing activity response: $e');
- print('๐ฆ Raw response: $responseBody');
+ AppLogger.spotify('โ Error parsing activity response: $e');
}
return [];
@@ -605,7 +595,7 @@ class SpotifyBuddyService {
) {
// Run in background without blocking the main response
Future.microtask(() async {
- print('๐ Starting progressive track duration fetch for ${tracksNeedingDuration.length} tracks...');
+ AppLogger.spotify('๐ Starting progressive track duration fetch for ${tracksNeedingDuration.length} tracks...');
var updatedActivities = List.from(activities);
@@ -618,7 +608,7 @@ class SpotifyBuddyService {
if (trackUri.isEmpty) continue;
- print('๐ Fetching duration for track: $trackUri (user: $userName)');
+ AppLogger.spotify('๐ Fetching duration for track: $trackUri (user: $userName)');
final durationMs = await _getTrackDuration(trackUri);
if (durationMs != null) {
@@ -653,7 +643,7 @@ class SpotifyBuddyService {
type: oldActivity.type,
);
- print('โ
Updated activity for $userName: ${oldActivity.track!.name} - Currently Playing: $isCurrentlyPlaying');
+ AppLogger.spotify('โ
Updated activity for $userName: ${oldActivity.track!.name} - Currently Playing: $isCurrentlyPlaying');
// Update cached activities
_cachedBuddyActivities = List.from(updatedActivities);
@@ -665,7 +655,7 @@ class SpotifyBuddyService {
}
}
} catch (e) {
- print('โ ๏ธ Failed to fetch duration for track at index $friendIndex: $e');
+ AppLogger.spotify('โ ๏ธ Failed to fetch duration for track at index $friendIndex: $e');
}
}
@@ -675,7 +665,7 @@ class SpotifyBuddyService {
_cacheModified = false;
}
- print('โ
Completed progressive track duration fetch');
+ AppLogger.spotify('โ
Completed progressive track duration fetch');
});
}
@@ -801,7 +791,7 @@ class SpotifyBuddyService {
while (attempt < maxRetries) {
try {
- print('๐ Attempting $operation (attempt ${attempt + 1}/$maxRetries)');
+ AppLogger.spotify('๐ Attempting $operation (attempt ${attempt + 1}/$maxRetries)');
return await apiCall().timeout(
const Duration(seconds: 30), // 30 second timeout per request
onTimeout: () {
@@ -812,12 +802,12 @@ class SpotifyBuddyService {
attempt++;
if (attempt >= maxRetries) {
- print('โ $operation failed after $maxRetries attempts: $e');
+ AppLogger.spotify('โ $operation failed after $maxRetries attempts: $e');
rethrow;
}
- print('โ ๏ธ $operation failed (attempt $attempt/$maxRetries): $e');
- print('๐ Retrying in ${delay.inMilliseconds}ms...');
+ AppLogger.spotify('โ ๏ธ $operation failed (attempt $attempt/$maxRetries): $e');
+ AppLogger.spotify('๐ Retrying in ${delay.inMilliseconds}ms...');
await Future.delayed(delay);
delay = Duration(milliseconds: (delay.inMilliseconds * 1.5).round()); // Exponential backoff
@@ -832,7 +822,7 @@ class SpotifyBuddyService {
Future getCurrentUserProfileWithToken(String bearerToken) async {
return _retryApiCall(
() async {
- print('๐ Getting user profile with Bearer token...');
+ AppLogger.spotify('๐ Getting user profile with Bearer token...');
final url = 'https://api.spotify.com/v1/me';
final headers = {
@@ -846,11 +836,11 @@ class SpotifyBuddyService {
headers: headers,
);
- print('๐ก User profile API response: ${response.statusCode}');
+ AppLogger.spotify('๐ก User profile API response: ${response.statusCode}');
if (response.statusCode == 200) {
final data = json.decode(response.body);
- print('โ
Successfully fetched user profile: ${data['display_name']}');
+ AppLogger.spotify('โ
Successfully fetched user profile: ${data['display_name']}');
return User.fromSpotifyApi(data);
} else {
@@ -876,19 +866,20 @@ class SpotifyBuddyService {
return await _retryApiCall(
() async {
- print('๐ Getting top content with GraphQL API...');
+ AppLogger.spotify('๐ Getting top content with GraphQL API...');
+ AppLogger.spotify(' - Time range: $timeRange');
+ AppLogger.spotify(' - Tracks limit: $tracksLimit (enabled: $includeTopTracks)');
+ AppLogger.spotify(' - Artists limit: $artistsLimit (enabled: $includeTopArtists)');
// Get Bearer token (direct only)
String? accessToken = getBearerToken();
+ String? clientToken = getClientToken();
if (accessToken == null) {
throw Exception('Failed to get access token for top content');
}
- print('โ
Got access token, fetching top content...');
-
- // Convert timeRange to GraphQL format
- final gqlTimeRange = _convertTimeRangeToGraphQL(timeRange);
+ AppLogger.spotify('โ
Got access token, fetching top content...');
final url = 'https://api-partner.spotify.com/pathfinder/v2/query';
final headers = {
@@ -896,10 +887,12 @@ class SpotifyBuddyService {
'accept-language': 'en',
'app-platform': 'WebPlayer',
'authorization': 'Bearer $accessToken',
- 'client-token': _generateClientToken(),
+ 'cache-control': 'no-cache',
+ 'client-token': clientToken ?? _generateClientToken(),
'content-type': 'application/json;charset=UTF-8',
'dnt': '1',
'origin': 'https://open.spotify.com',
+ 'pragma': 'no-cache',
'priority': 'u=1, i',
'referer': 'https://open.spotify.com/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
@@ -908,8 +901,8 @@ class SpotifyBuddyService {
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
- 'spotify-app-version': '1.2.66.322.g4d62a810',
- 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/537.36',
+ 'spotify-app-version': '1.2.67.546.ga043c80d',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
'Cookie': _completeCookieString!,
};
@@ -920,23 +913,23 @@ class SpotifyBuddyService {
'offset': 0,
'limit': artistsLimit,
'sortBy': 'AFFINITY',
- 'timeRange': gqlTimeRange,
+ 'timeRange': 'SHORT_TERM'
},
'includeTopTracks': includeTopTracks,
'topTracksInput': {
'offset': 0,
'limit': tracksLimit,
'sortBy': 'AFFINITY',
- 'timeRange': gqlTimeRange,
- },
+ 'timeRange': 'SHORT_TERM'
+ }
},
'operationName': 'userTopContent',
'extensions': {
'persistedQuery': {
'version': 1,
- 'sha256Hash': 'feb6d55177e2cbce2ac59214f9493f1ef2e4368eec01b3d4c3468fa1b97336e2',
- },
- },
+ 'sha256Hash': 'feb6d55177e2cbce2ac59214f9493f1ef2e4368eec01b3d4c3468fa1b97336e2'
+ }
+ }
};
final response = await HttpInterceptor.post(
@@ -945,20 +938,83 @@ class SpotifyBuddyService {
body: json.encode(requestBody),
);
- print('๐ก Top content GraphQL API response: ${response.statusCode}');
-
+ AppLogger.spotify('๐ก Top content API response: ${response.statusCode}');
+
if (response.statusCode == 200) {
final data = json.decode(response.body);
- print('โ
Successfully fetched top content');
+ final topArtistsCount = data['data']?['me']?['profile']?['topArtists']?['totalCount'] ?? 0;
+ final topTracksCount = data['data']?['me']?['profile']?['topTracks']?['totalCount'] ?? 0;
+ AppLogger.spotify('โ
Successfully fetched top content (artists: $topArtistsCount, tracks: $topTracksCount)');
return data;
+ } else if (response.statusCode == 401 || response.statusCode == 403) {
+ AppLogger.spotify('๐ Access token unauthorized, clearing cache and retrying...');
+ AppLogger.spotify('Response body: ${response.body}');
+ _completeCookieString = null;
+
+ // Retry with the same request
+ final retryHeaders = {
+ 'accept': 'application/json',
+ 'accept-language': 'en',
+ 'app-platform': 'WebPlayer',
+ 'authorization': 'Bearer $accessToken',
+ 'cache-control': 'no-cache',
+ 'client-token': _generateClientToken(),
+ 'content-type': 'application/json;charset=UTF-8',
+ 'dnt': '1',
+ 'origin': 'https://open.spotify.com',
+ 'pragma': 'no-cache',
+ 'priority': 'u=1, i',
+ 'referer': 'https://open.spotify.com/',
+ 'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Windows"',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-site',
+ 'spotify-app-version': '1.2.67.546.ga043c80d',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
+ 'Cookie': _completeCookieString!,
+ };
+
+ try {
+ // Log retry curl command for debugging
+ AppLogger.spotify('๐ก Retry request curl command:');
+ AppLogger.spotify(_generateCurlCommand('POST', url, retryHeaders, body: json.encode(requestBody)));
+
+ final retryResponse = await HttpInterceptor.post(
+ Uri.parse(url),
+ headers: retryHeaders,
+ body: json.encode(requestBody),
+ );
+
+ AppLogger.spotify('๐ก Retry response: ${retryResponse.statusCode}');
+
+ if (retryResponse.statusCode == 200) {
+ final data = json.decode(retryResponse.body);
+ final topArtistsCount = data['data']?['me']?['profile']?['topArtists']?['totalCount'] ?? 0;
+ final topTracksCount = data['data']?['me']?['profile']?['topTracks']?['totalCount'] ?? 0;
+ AppLogger.spotify('โ
Successfully fetched top content on retry:');
+ AppLogger.spotify(' - Total artists available: $topArtistsCount');
+ AppLogger.spotify(' - Total tracks available: $topTracksCount');
+ return data;
+ } else {
+ AppLogger.spotify('โ Retry also failed with status: ${retryResponse.statusCode}');
+ throw Exception('Failed to fetch top content: ${retryResponse.statusCode}');
+ }
+ } catch (retryError) {
+ AppLogger.spotify('โ Error during retry request: $retryError');
+ throw Exception('Network error during retry: $retryError');
+ }
} else {
+ AppLogger.spotify('โ Failed to fetch top content: ${response.statusCode}');
+ AppLogger.spotify(' Response body: ${response.body}');
throw Exception('Failed to fetch top content: ${response.statusCode} - ${response.body}');
}
},
operation: 'Get Top Content',
);
} catch (e) {
- print('โ Error in getTopContent: $e');
+ AppLogger.spotify('โ Error in getTopContent: $e');
return null;
}
}
@@ -975,7 +1031,7 @@ class SpotifyBuddyService {
);
if (data == null) {
- print('โ ๏ธ Failed to get top content data for tracks');
+ AppLogger.spotify('โ ๏ธ Failed to get top content data for tracks');
return [];
}
@@ -997,13 +1053,26 @@ class SpotifyBuddyService {
final coverArt = albumData['coverArt']?['sources'] as List? ?? [];
String? imageUrl;
if (coverArt.isNotEmpty) {
+ // Try to find an image close to 300x300 first (good for display)
for (final image in coverArt) {
final height = image['height'] as int?;
- if (height != null && height >= 300 && height <= 640) {
+ if (height != null && height == 300) {
imageUrl = image['url'] as String?;
break;
}
}
+ // If no 300x300 found, use the highest resolution
+ if (imageUrl == null) {
+ var maxHeight = 0;
+ for (final image in coverArt) {
+ final height = image['height'] as int?;
+ if (height != null && height > maxHeight) {
+ maxHeight = height;
+ imageUrl = image['url'] as String?;
+ }
+ }
+ }
+ // Fallback to first image if no suitable size found
imageUrl ??= coverArt.first['url'] as String?;
}
@@ -1020,10 +1089,10 @@ class SpotifyBuddyService {
}
}
- print('โ
Parsed ${tracks.length} top tracks from GraphQL response');
+
return tracks;
} catch (e) {
- print('โ Error in getTopTracks: $e');
+ AppLogger.spotify('โ Error in getTopTracks: $e');
return [];
}
}
@@ -1040,7 +1109,7 @@ class SpotifyBuddyService {
);
if (data == null) {
- print('โ ๏ธ Failed to get top content data for artists');
+ AppLogger.spotify('โ ๏ธ Failed to get top content data for artists');
return [];
}
@@ -1059,13 +1128,26 @@ class SpotifyBuddyService {
final avatarImages = artistData['visuals']?['avatarImage']?['sources'] as List? ?? [];
String? imageUrl;
if (avatarImages.isNotEmpty) {
+ // Try to find a 320x320 image first (good balance of quality and size)
for (final image in avatarImages) {
final height = image['height'] as int?;
- if (height != null && height >= 300 && height <= 640) {
+ if (height != null && height == 320) {
imageUrl = image['url'] as String?;
break;
}
}
+ // If no 320x320 found, use the highest resolution
+ if (imageUrl == null) {
+ var maxHeight = 0;
+ for (final image in avatarImages) {
+ final height = image['height'] as int?;
+ if (height != null && height > maxHeight) {
+ maxHeight = height;
+ imageUrl = image['url'] as String?;
+ }
+ }
+ }
+ // Fallback to first image if no suitable size found
imageUrl ??= avatarImages.first['url'] as String?;
}
@@ -1081,7 +1163,7 @@ class SpotifyBuddyService {
followers = cachedDetails['followers'] ?? -1;
genres = (cachedDetails['genres'] as List?)?.map((g) => g.toString()).toList() ?? [];
popularity = cachedDetails['popularity'] ?? 0;
- print('๐พ Using cached artist details for $artistId: $followers followers');
+
}
artists.add(Artist(
@@ -1096,11 +1178,11 @@ class SpotifyBuddyService {
}
}
- print('โ
Parsed ${artists.length} top artists from GraphQL response');
+
return artists;
} catch (e) {
- print('โ Error in getTopArtists: $e');
+ AppLogger.spotify('โ Error in getTopArtists: $e');
return [];
}
}
@@ -1131,12 +1213,9 @@ class SpotifyBuddyService {
}
if (missingIds.isEmpty) {
- print('โ
All artist details already cached');
return artists;
}
- print('๐ Fetching details for ${missingIds.length} artists in background...');
-
var updatedArtists = List.from(artists);
for (final artistId in missingIds) {
@@ -1165,7 +1244,7 @@ class SpotifyBuddyService {
uri: oldArtist.uri,
);
- print('โ
Updated artist $artistId with ${artistDetails['followers']?['total']} followers');
+
// Notify callback with updated list
if (onUpdate != null) {
@@ -1174,7 +1253,7 @@ class SpotifyBuddyService {
}
}
} catch (e) {
- print('โ ๏ธ Failed to fetch details for artist $artistId: $e');
+ AppLogger.spotify('โ ๏ธ Failed to fetch details for artist $artistId: $e');
// Cache a placeholder to avoid repeated failures
_artistDetailsCache[artistId] = {
'followers': 0,
@@ -1244,15 +1323,15 @@ class SpotifyBuddyService {
if (cachedAt != null && (now - cachedAt) < (7 * 24 * 60 * 60 * 1000)) {
_artistDetailsCache[artistId] = Map.from(details);
} else {
- print('๐๏ธ Artist details cache expired for $artistId');
+
}
}
});
- print('๐พ Loaded ${_artistDetailsCache.length} artist details from cache');
+
}
} catch (e) {
- print('โ ๏ธ Failed to load artist details cache: $e');
+ AppLogger.spotify('โ ๏ธ Failed to load artist details cache: $e');
_artistDetailsCache.clear();
}
}
@@ -1263,29 +1342,22 @@ class SpotifyBuddyService {
final prefs = await SharedPreferences.getInstance();
final cacheJson = json.encode(_artistDetailsCache);
await prefs.setString(_artistDetailsCacheKey, cacheJson);
- print('๐พ Saved ${_artistDetailsCache.length} artist details to cache');
+ AppLogger.spotify('๐พ Saved ${_artistDetailsCache.length} artist details to cache');
} catch (e) {
- print('โ ๏ธ Failed to save artist details cache: $e');
+ AppLogger.spotify('โ ๏ธ Failed to save artist details cache: $e');
}
}
/// Converts time range from REST API format to GraphQL format
String _convertTimeRangeToGraphQL(String timeRange) {
- switch (timeRange) {
- case 'short_term':
- return 'SHORT_TERM';
- case 'medium_term':
- return 'MEDIUM_TERM';
- case 'long_term':
- return 'LONG_TERM';
- default:
- return 'SHORT_TERM';
- }
+ // Always return SHORT_TERM as that's what works with the current GraphQL schema
+ return 'SHORT_TERM';
}
/// Generates a client token for the GraphQL API
- /// This is a simplified version - in a real app, this would be more complex
+ /// This is a fallback in case we don't have an intercepted token
String _generateClientToken() {
+ AppLogger.spotify('โ ๏ธ Using fallback client token - this should be replaced with an intercepted token');
// This is a placeholder - the actual client token generation is complex
// For now, we'll use a static token from the example
return 'AAAyrFCYuQiGGFsq0OYbkiotiZ9YtDPzdjemsDOtMJ6msHslHFxskOjd1h1q28igZTPhiB+n++o4n7/QkdHbIuzznY/QOKMesZKLlV83stuo8yn7hiiDN1R7b0HyInceiDZUgEPotzcBSM7v9ff76LEOJ53Hxl4W8qp+bwi+WAMlKSG6LSKb4905Tyqj0p2nsnWblSZVUw+Lj7huYgvu2y4istr4/zCyTIed9nI6ys3M2C8yhYfF1+5PC58l5gwGasCb7J7EikdPOfjBXZlMfMfh3gnOP4mK1ITzqmfaevpbrZDkJpspdzZFtYJT2eax';
diff --git a/lib/services/spotify_service.dart b/lib/services/spotify_service.dart
index 6c6c182..41aa9a2 100644
--- a/lib/services/spotify_service.dart
+++ b/lib/services/spotify_service.dart
@@ -4,6 +4,7 @@ import '../models/user.dart';
import '../models/track.dart';
import 'spotify_buddy_service.dart';
import 'http_interceptor.dart';
+import 'app_logger.dart';
class SpotifyService {
static const String baseUrl = 'https://api.spotify.com/v1';
@@ -100,12 +101,12 @@ class SpotifyService {
// No content - nothing is currently playing
return null;
} else {
- print('Failed to get currently playing: ${response.statusCode} - ${response.body}');
+ AppLogger.spotify('Failed to get currently playing: ${response.statusCode} - ${response.body}');
return null;
}
} catch (e) {
- print('โ Error getting currently playing: $e');
+ AppLogger.spotify('Error getting currently playing', e);
return null;
}
},
@@ -126,7 +127,7 @@ class SpotifyService {
while (attempt < maxRetries) {
try {
- print('๐ Attempting $operation (attempt ${attempt + 1}/$maxRetries)');
+ AppLogger.spotify('Attempting $operation (attempt ${attempt + 1}/$maxRetries)');
return await apiCall().timeout(
const Duration(seconds: 15), // 15 second timeout per request
onTimeout: () {
@@ -137,12 +138,12 @@ class SpotifyService {
attempt++;
if (attempt >= maxRetries) {
- print('โ $operation failed after $maxRetries attempts: $e');
+ AppLogger.error('$operation failed after $maxRetries attempts', e);
rethrow;
}
- print('โ ๏ธ $operation failed (attempt $attempt/$maxRetries): $e');
- print('๐ Retrying in ${delay.inMilliseconds}ms...');
+ AppLogger.warning('$operation failed (attempt $attempt/$maxRetries)', e);
+ AppLogger.spotify('Retrying in ${delay.inMilliseconds}ms...');
await Future.delayed(delay);
delay = Duration(milliseconds: (delay.inMilliseconds * 1.5).round()); // Exponential backoff
diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart
new file mode 100644
index 0000000..53bd514
--- /dev/null
+++ b/lib/services/update_service.dart
@@ -0,0 +1,1345 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:http/http.dart' as http;
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:path_provider/path_provider.dart';
+import '../utils/version_utils.dart';
+import '../utils/update_launcher.dart';
+import 'app_logger.dart';
+
+class UpdateService {
+ // The base URL for checking updates
+ // static const String _baseUrl = 'https://raw.githubusercontent.com/mliem2k/playtivity/main';
+ static const String _githubReleasesApi = 'https://api.github.com/repos/mliem2k/playtivity/releases';
+ // static const String _nightlyInfoPath = 'nightly/latest-nightly-info.json';
+
+ // Preference keys
+ static const String _prefLastCheckTime = 'last_update_check_time';
+ static const String _prefEnableNightly = 'enable_nightly_builds';
+ static const String _prefCheckFrequency = 'update_check_frequency_hours';
+ static const String _prefAutoDownload = 'auto_download_updates';
+
+ // Update check information
+ static UpdateInfo? _latestReleaseInfo;
+ static UpdateInfo? _latestNightlyInfo;
+ static bool _isCheckingForUpdates = false;
+
+ // Get the current app version information
+ static Future getCurrentAppVersion() async {
+ final PackageInfo packageInfo = await PackageInfo.fromPlatform();
+ return AppVersionInfo(
+ version: packageInfo.version,
+ buildNumber: packageInfo.buildNumber,
+ );
+ }
+
+ // Check if user has opted in for nightly builds
+ static Future getNightlyBuildPreference() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_prefEnableNightly) ?? false;
+ }
+
+ // Set the nightly build preference
+ static Future setNightlyBuildPreference(bool value) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_prefEnableNightly, value);
+ }
+
+ // Get update check frequency in hours
+ static Future getCheckFrequency() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getInt(_prefCheckFrequency) ?? 24; // Default to daily
+ }
+
+ // Set update check frequency
+ static Future setCheckFrequency(int hours) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt(_prefCheckFrequency, hours);
+ }
+
+ // Get auto download preference
+ static Future getAutoDownloadPreference() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_prefAutoDownload) ?? false; // Default to manual
+ }
+
+ // Set auto download preference
+ static Future setAutoDownloadPreference(bool value) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_prefAutoDownload, value);
+ }
+
+ // Should we check for updates now?
+ static Future shouldCheckForUpdates() async {
+ final prefs = await SharedPreferences.getInstance();
+ final lastCheck = prefs.getInt(_prefLastCheckTime) ?? 0;
+ final now = DateTime.now().millisecondsSinceEpoch;
+ final checkFrequencyMs = await getCheckFrequency() * 60 * 60 * 1000;
+
+ // Check if it's been long enough since the last check
+ return (now - lastCheck) >= checkFrequencyMs;
+ }
+
+ // Mark that we've checked for updates
+ static Future _markUpdateChecked() async {
+ final prefs = await SharedPreferences.getInstance();
+ final now = DateTime.now().millisecondsSinceEpoch;
+ await prefs.setInt(_prefLastCheckTime, now);
+ }
+
+ // Check if current version is a nightly build
+ static bool isCurrentVersionNightly(String version) {
+ return version.contains('-nightly-');
+ }
+
+ // Auto-enable nightly builds if user is already on a nightly version
+ static Future autoEnableNightlyIfApplicable() async {
+ try {
+ final currentVersion = await getCurrentAppVersion();
+ final isNightly = isCurrentVersionNightly(currentVersion.version);
+ final nightlyEnabled = await getNightlyBuildPreference();
+
+ if (isNightly && !nightlyEnabled) {
+ AppLogger.info('Current version is nightly but preference is disabled. Auto-enabling nightly builds.');
+ await setNightlyBuildPreference(true);
+ AppLogger.info('Nightly builds preference automatically enabled');
+ }
+ } catch (e) {
+ AppLogger.error('Error in auto-enable nightly check', e);
+ }
+ }
+
+ // Check for updates (both release and nightly)
+ static Future checkForUpdates({bool forceCheck = false}) async {
+ // Don't check multiple times simultaneously
+ if (_isCheckingForUpdates) {
+ AppLogger.info('Update check already in progress, skipping duplicate check');
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'Update check already in progress',
+ );
+ }
+
+ // Don't check if not enough time has passed
+ if (!forceCheck && !(await shouldCheckForUpdates())) {
+ AppLogger.info('Skipping update check, too soon since last check');
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'Too soon since last check',
+ );
+ }
+
+ try {
+ _isCheckingForUpdates = true;
+
+ // Get current device information
+ final currentVersion = await getCurrentAppVersion();
+
+ // Auto-enable nightly builds if user is on a nightly version
+ await autoEnableNightlyIfApplicable();
+
+ final useNightly = await getNightlyBuildPreference();
+
+ AppLogger.info('Checking for updates...');
+ AppLogger.info('Current version: ${currentVersion.version}+${currentVersion.buildNumber}');
+ AppLogger.info('Is nightly version: ${isCurrentVersionNightly(currentVersion.version)}');
+ AppLogger.info('Nightly builds enabled: $useNightly');
+
+ // Always check for regular release updates first
+ final releaseCheck = await _checkReleaseUpdates(currentVersion);
+
+ // Check for nightly updates if user has opted in
+ UpdateCheckResult? nightlyCheck;
+ if (useNightly) {
+ nightlyCheck = await _checkNightlyUpdates(currentVersion);
+ }
+
+ // Mark that we've checked
+ await _markUpdateChecked();
+
+ // Determine if there's an update available
+ // For nightly users, prioritize nightly updates and be more selective about stable updates
+ if (useNightly) {
+ AppLogger.info('User has nightly builds enabled');
+
+ // If there's a nightly update available, prefer it
+ if (nightlyCheck != null && nightlyCheck.hasUpdate) {
+ AppLogger.info('Nightly update available: ${nightlyCheck.updateInfo?.version}');
+ return nightlyCheck;
+ }
+
+ // For nightly users, only offer stable updates if they're significantly newer
+ if (releaseCheck.hasUpdate && _shouldOfferStableToNightlyUser(currentVersion, releaseCheck)) {
+ AppLogger.info('Stable release update available for nightly user: ${releaseCheck.updateInfo?.version}');
+ return releaseCheck;
+ }
+
+ AppLogger.info('No suitable updates available for nightly user');
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ );
+ }
+
+ // For stable users, only offer release updates
+ if (releaseCheck.hasUpdate) {
+ AppLogger.info('Release update available: ${releaseCheck.updateInfo?.version}');
+ return releaseCheck;
+ }
+
+ // No update available
+ AppLogger.info('No updates available');
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ );
+
+ } catch (e) {
+ AppLogger.error('Error checking for updates', e);
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'Error checking for updates: ${e.toString()}',
+ );
+ } finally {
+ _isCheckingForUpdates = false;
+ }
+ }
+
+ // Check for release updates
+ static Future _checkReleaseUpdates(AppVersionInfo currentVersion) async {
+ try {
+ // Fetch the release information from GitHub API
+ final response = await http.get(Uri.parse(_githubReleasesApi));
+
+ if (response.statusCode != 200) {
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'Failed to fetch release info: HTTP ${response.statusCode}',
+ );
+ }
+
+ final List releasesJson = json.decode(response.body);
+
+ // Find the latest stable release (exclude nightly releases)
+ Map? latestReleaseJson;
+ for (final releaseJson in releasesJson) {
+ final tagName = releaseJson['tag_name'] as String? ?? '';
+ // Skip nightly releases - we only want stable releases here
+ if (!tagName.startsWith('nightly-')) {
+ latestReleaseJson = releaseJson as Map;
+ break; // First non-nightly release is the latest stable
+ }
+ }
+
+ if (latestReleaseJson == null) {
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'No stable releases found',
+ );
+ }
+
+ final releaseInfo = UpdateInfo.fromGithubJson(latestReleaseJson, isNightly: false);
+ _latestReleaseInfo = releaseInfo;
+
+ // Compare versions
+ final hasUpdate = VersionUtils.isNewerVersion(
+ currentVersion: currentVersion.version,
+ newVersion: releaseInfo.version,
+ );
+
+ AppLogger.info('Release check: Current=${currentVersion.version}, Latest=${releaseInfo.version}, hasUpdate=$hasUpdate');
+
+ return UpdateCheckResult(
+ hasUpdate: hasUpdate,
+ isNightly: false,
+ updateInfo: hasUpdate ? releaseInfo : null,
+ );
+ } catch (e) {
+ AppLogger.error('Error checking release updates', e);
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: false,
+ updateInfo: null,
+ error: 'Error checking release updates: ${e.toString()}',
+ );
+ }
+ }
+ // Check for nightly updates
+ static Future _checkNightlyUpdates(AppVersionInfo currentVersion) async {
+ try {
+ // Fetch nightly releases from GitHub API
+ final response = await http.get(Uri.parse(_githubReleasesApi));
+
+ if (response.statusCode != 200) {
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: true,
+ updateInfo: null,
+ error: 'Failed to fetch nightly releases: HTTP ${response.statusCode}',
+ );
+ }
+
+ final List releasesJson = json.decode(response.body);
+
+ // Find the latest nightly release (tagged with 'nightly-' prefix)
+ Map? latestNightlyJson;
+ for (final releaseJson in releasesJson) {
+ final tagName = releaseJson['tag_name'] as String? ?? '';
+ if (tagName.startsWith('nightly-')) {
+ latestNightlyJson = releaseJson as Map;
+ break; // First one is the latest since releases are sorted by date
+ }
+ }
+
+ if (latestNightlyJson == null) {
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: true,
+ updateInfo: null,
+ error: 'No nightly releases found',
+ );
+ }
+
+ final nightlyInfo = UpdateInfo.fromGithubJson(latestNightlyJson, isNightly: true);
+ _latestNightlyInfo = nightlyInfo;
+
+ // For nightlies, we compare build dates or if current version doesn't contain nightly
+ final hasUpdate = _shouldUpdateToNightly(currentVersion.version, nightlyInfo);
+
+ AppLogger.info('Nightly check: Latest=${nightlyInfo.version}, hasUpdate=$hasUpdate');
+
+ return UpdateCheckResult(
+ hasUpdate: hasUpdate,
+ isNightly: true,
+ updateInfo: hasUpdate ? nightlyInfo : null,
+ );
+ } catch (e) {
+ AppLogger.error('Error checking nightly updates', e);
+ return UpdateCheckResult(
+ hasUpdate: false,
+ isNightly: true,
+ updateInfo: null,
+ error: 'Error checking nightly updates: ${e.toString()}',
+ );
+ }
+ }
+
+ // Helper method to determine if we should offer a stable release to a nightly user
+ static bool _shouldOfferStableToNightlyUser(AppVersionInfo currentVersion, UpdateCheckResult releaseCheck) {
+ if (releaseCheck.updateInfo == null) return false;
+
+ final currentVersionString = currentVersion.version;
+ final releaseVersionString = releaseCheck.updateInfo!.version;
+
+ AppLogger.info('Checking if should offer stable to nightly user:');
+ AppLogger.info(' Current nightly: $currentVersionString');
+ AppLogger.info(' Available stable: $releaseVersionString');
+
+ // Extract base versions for comparison
+ final currentBase = VersionUtils.extractBaseVersion(currentVersionString);
+ final releaseBase = VersionUtils.extractBaseVersion(releaseVersionString);
+
+ AppLogger.info(' Current base: $currentBase');
+ AppLogger.info(' Release base: $releaseBase');
+
+ // Only offer stable if it has a higher major or minor version
+ // Don't offer for patch-level updates to avoid unnecessary downgrades
+ final currentBaseParts = currentBase.split('.');
+ final releaseBaseParts = releaseBase.split('.');
+
+ // Normalize to have at least 3 parts
+ while (currentBaseParts.length < 3) {
+ currentBaseParts.add('0');
+ }
+ while (releaseBaseParts.length < 3) {
+ releaseBaseParts.add('0');
+ }
+
+ final currentMajor = int.tryParse(currentBaseParts[0]) ?? 0;
+ final currentMinor = int.tryParse(currentBaseParts[1]) ?? 0;
+
+ final releaseMajor = int.tryParse(releaseBaseParts[0]) ?? 0;
+ final releaseMinor = int.tryParse(releaseBaseParts[1]) ?? 0;
+
+ // Only offer if it's a major or minor version increase
+ final shouldOffer = (releaseMajor > currentMajor) ||
+ (releaseMajor == currentMajor && releaseMinor > currentMinor);
+
+ AppLogger.info('Should offer stable to nightly user: $shouldOffer');
+
+ return shouldOffer;
+ }
+
+ // Helper method to determine if we should update to a nightly build
+ static bool _shouldUpdateToNightly(String currentVersion, UpdateInfo nightlyInfo) {
+ AppLogger.info('Checking if should update to nightly:');
+ AppLogger.info(' Current version: $currentVersion');
+ AppLogger.info(' New nightly version: ${nightlyInfo.version}');
+ AppLogger.info(' New nightly build date: ${nightlyInfo.buildDate}');
+
+ // If current version is not a nightly, always offer nightly update
+ if (!currentVersion.contains('nightly')) {
+ AppLogger.info('Current version is not nightly, offering nightly update');
+ return true;
+ }
+
+ // If both are nightly, compare build dates
+ final shouldUpdate = VersionUtils.isNewerNightly(
+ currentVersion: currentVersion,
+ newVersion: nightlyInfo.version,
+ newBuildTime: nightlyInfo.buildDate,
+ );
+
+ AppLogger.info('Should update to nightly: $shouldUpdate');
+ return shouldUpdate;
+ }
+
+ // Download an update file with progress tracking
+ static Future downloadUpdate(
+ UpdateInfo updateInfo, {
+ Function(DownloadProgress)? onProgress,
+ }) async {
+ try {
+ AppLogger.info('Downloading update: ${updateInfo.version} (${updateInfo.apkUrl})');
+
+ // Get the download directory
+ final directory = await getTemporaryDirectory();
+ final filePath = '${directory.path}/${updateInfo.apkFileName}';
+
+ // Create the file
+ final file = File(filePath);
+
+ // Start streaming download
+ final client = http.Client();
+ final request = http.Request('GET', Uri.parse(updateInfo.apkUrl));
+ final response = await client.send(request);
+
+ if (response.statusCode != 200) {
+ return UpdateDownloadResult(
+ success: false,
+ filePath: null,
+ error: 'Failed to download update: HTTP ${response.statusCode}',
+ );
+ }
+
+ // Get total file size
+ final totalBytes = response.contentLength ?? updateInfo.fileSizeBytes;
+ AppLogger.info('Download size: $totalBytes bytes');
+
+ // Prepare for streaming download
+ int downloadedBytes = 0;
+ final stopwatch = Stopwatch()..start();
+ final sink = file.openWrite();
+
+ try {
+ await for (final chunk in response.stream) {
+ downloadedBytes += chunk.length;
+ sink.add(chunk);
+
+ // Calculate progress and speed
+ final elapsedMs = stopwatch.elapsedMilliseconds;
+ final progress = totalBytes > 0 ? downloadedBytes / totalBytes : 0.0;
+ final speedBytesPerSecond = elapsedMs > 0 ? (downloadedBytes * 1000) / elapsedMs : 0.0;
+
+ // Estimate remaining time
+ final remainingBytes = totalBytes - downloadedBytes;
+ final estimatedRemainingSeconds = speedBytesPerSecond > 0
+ ? remainingBytes / speedBytesPerSecond
+ : 0.0;
+
+ // Report progress
+ if (onProgress != null) {
+ final progressData = DownloadProgress(
+ downloadedBytes: downloadedBytes,
+ totalBytes: totalBytes,
+ progress: progress,
+ speedBytesPerSecond: speedBytesPerSecond,
+ estimatedRemainingSeconds: estimatedRemainingSeconds,
+ );
+ onProgress(progressData);
+ }
+ }
+ } finally {
+ await sink.close();
+ client.close();
+ }
+
+ AppLogger.info('Update downloaded successfully: $filePath');
+
+ return UpdateDownloadResult(
+ success: true,
+ filePath: filePath,
+ updateInfo: updateInfo,
+ );
+ } catch (e) {
+ AppLogger.error('Error downloading update', e);
+ return UpdateDownloadResult(
+ success: false,
+ filePath: null,
+ error: 'Error downloading update: ${e.toString()}',
+ );
+ }
+ }
+
+ // Show update dialog to the user
+ static Future showUpdateDialog(BuildContext context, UpdateInfo updateInfo) async {
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ title: Text(updateInfo.isNightly ? 'Nightly Update Available' : 'Update Available'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('A new ${updateInfo.isNightly ? 'nightly' : 'release'} version is available: ${updateInfo.version}'),
+ const SizedBox(height: 8),
+ if (updateInfo.changelog != null && updateInfo.changelog!.isNotEmpty) ...[
+ const Text('Changes:'),
+ const SizedBox(height: 4),
+ Text(updateInfo.changelog!, style: const TextStyle(fontSize: 14)),
+ const SizedBox(height: 8),
+ ],
+ if (updateInfo.isNightly)
+ const Text('โ ๏ธ Nightly builds may contain bugs or incomplete features.',
+ style: TextStyle(color: Colors.orange),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(context).pop(false);
+ },
+ child: const Text('Later'),
+ ),
+ ElevatedButton(
+ onPressed: () {
+ Navigator.of(context).pop(true);
+ },
+ child: const Text('Update Now'),
+ ),
+ ],
+ );
+ },
+ ) ?? false;
+ }
+
+ // Show a download progress dialog with enhanced UI
+ static Future showDownloadDialog(BuildContext context, UpdateInfo updateInfo) async {
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext context) {
+ return _DownloadProgressDialog(updateInfo: updateInfo);
+ },
+ );
+ }
+
+ // Install a downloaded update
+ static Future installUpdate(String filePath) async {
+ try {
+ AppLogger.info('Installing update: $filePath');
+
+ // Check if we have permission to install packages
+ final canInstall = await UpdateLauncher.canInstallPackages();
+ if (!canInstall) {
+ AppLogger.warning('No permission to install packages');
+ return false;
+ }
+
+ return await UpdateLauncher.installApk(filePath);
+ } catch (e) {
+ AppLogger.error('Error installing update', e);
+ return false;
+ }
+ }
+
+ // Show an installation progress/instruction dialog
+ static Future showInstallDialog(BuildContext context, String filePath) async {
+ return await showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ title: const Text('Install Update'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('The update has been downloaded and is ready to install.'),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.info, color: Colors.orange, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'You may need to allow "Unknown sources" in your device settings to install this update.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.of(context).pop(false);
+ },
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ // First, check if we have permission to install packages
+ final canInstall = await UpdateLauncher.canInstallPackages();
+
+ if (!canInstall && context.mounted) {
+ // Show permission request dialog
+ final shouldRequestPermission = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Permission Required'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('To install updates, Playtivity needs permission to install applications.'),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.blue.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.info, color: Colors.blue, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'You will be taken to system settings where you can enable "Allow from this source" for Playtivity.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Grant Permission'),
+ ),
+ ],
+ ),
+ ) ?? false;
+
+ if (!shouldRequestPermission) {
+ if (context.mounted) {
+ Navigator.of(context).pop(false);
+ }
+ return;
+ }
+
+ // Request permission
+ await UpdateLauncher.requestInstallPermission();
+
+ // Show instruction dialog
+ if (context.mounted) {
+ await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Complete Permission Setup'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('After enabling the permission:'),
+ const SizedBox(height: 8),
+ const Text('1. Tap the back button to return to Playtivity'),
+ const Text('2. Try installing the update again'),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.info, color: Colors.orange, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'This permission is only used for app updates and is completely safe.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ if (context.mounted) {
+ Navigator.of(context).pop(false);
+ }
+ return;
+ }
+
+ // Show loading state
+ if (context.mounted) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) => const AlertDialog(
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Center(child: CircularProgressIndicator()),
+ SizedBox(height: 16),
+ Text('Starting installation...'),
+ ],
+ ),
+ ),
+ );
+ }
+
+ try {
+ final success = await installUpdate(filePath);
+
+ // Close loading dialog
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+
+ if (!success && context.mounted) {
+ // Check permission again to provide better error message
+ final hasPermission = await UpdateLauncher.canInstallPackages();
+
+ if (context.mounted) {
+ await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Installation Failed'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(hasPermission
+ ? 'Failed to start APK installation. This could be due to:'
+ : 'Installation permission is not granted.'),
+ const SizedBox(height: 8),
+ if (hasPermission) ...[
+ const Text('โข File permissions issue'),
+ const Text('โข Corrupted download file'),
+ const Text('โข Device storage space'),
+ const SizedBox(height: 12),
+ const Text('Please try:'),
+ const Text('1. Re-download the update'),
+ const Text('2. Check device storage space'),
+ const Text('3. Restart the app and try again'),
+ ] else ...[
+ const Text('Please enable "Allow from this source" for Playtivity in:'),
+ const Text('Settings > Apps > Special access > Install unknown apps'),
+ ],
+ ],
+ ),
+ actions: [
+ if (!hasPermission)
+ TextButton(
+ onPressed: () async {
+ Navigator.of(context).pop();
+ await UpdateLauncher.requestInstallPermission();
+ },
+ child: const Text('Open Settings'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+ }
+ } else {
+ // Installation started successfully
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Installation started! Follow the on-screen prompts.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ }
+
+ if (context.mounted) {
+ Navigator.of(context).pop(success);
+ }
+ } catch (e) {
+ // Close loading dialog
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+
+ // Show error dialog
+ if (context.mounted) {
+ await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Installation Error'),
+ content: Text('An unexpected error occurred:\n\n$e'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+
+ if (context.mounted) {
+ Navigator.of(context).pop(false);
+ }
+ }
+ }
+ },
+ child: const Text('Install Now'),
+ ),
+ ],
+ );
+ },
+ ) ?? false;
+ }
+
+ // Get the latest release info (if already fetched)
+ static UpdateInfo? getLatestReleaseInfo() {
+ return _latestReleaseInfo;
+ }
+
+ // Get the latest nightly info (if already fetched)
+ static UpdateInfo? getLatestNightlyInfo() {
+ return _latestNightlyInfo;
+ }
+}
+
+// Data classes for update information
+class UpdateInfo {
+ final String version;
+ final String buildNumber;
+ final DateTime buildDate;
+ final String apkUrl;
+ final String apkFileName;
+ final String? changelog;
+ final bool isNightly;
+ final int fileSizeBytes;
+
+ UpdateInfo({
+ required this.version,
+ required this.buildNumber,
+ required this.buildDate,
+ required this.apkUrl,
+ required this.apkFileName,
+ this.changelog,
+ required this.isNightly,
+ required this.fileSizeBytes,
+ });
+
+ factory UpdateInfo.fromJson(Map json, {required bool isNightly}) {
+ // Different JSON structure for release vs nightly
+ if (isNightly) {
+ final version = json['version'] as Map;
+ final apk = json['apk'] as Map;
+ final git = json['git'] as Map;
+
+ return UpdateInfo(
+ version: version['versionName'] as String,
+ buildNumber: version['buildNumber'].toString(),
+ buildDate: DateTime.parse(json['buildDate'] as String),
+ apkUrl: 'https://github.com/mliem/playtivity/raw/main/nightly/${apk['fileName']}',
+ apkFileName: apk['fileName'] as String,
+ changelog: git['commitMessage'] as String?,
+ isNightly: true,
+ fileSizeBytes: apk['sizeBytes'] as int,
+ );
+ } else {
+ final version = json['version'] as Map;
+ final apkFile = (json['files'] as List).firstWhere(
+ (f) => f['type'] == 'APK',
+ orElse: () => throw Exception('No APK file found in release info'),
+ ) as Map;
+
+ return UpdateInfo(
+ version: version['versionName'] as String,
+ buildNumber: version['buildNumber'].toString(),
+ buildDate: DateTime.parse(json['timestamp'] as String),
+ apkUrl: 'https://github.com/mliem/playtivity/raw/main/releases/${apkFile['name']}',
+ apkFileName: apkFile['name'] as String,
+ changelog: json['changelog'] as String?,
+ isNightly: false,
+ fileSizeBytes: apkFile['sizeBytes'] as int,
+ );
+ }
+ }
+ factory UpdateInfo.fromGithubJson(Map json, {required bool isNightly}) {
+ // JSON structure for GitHub releases API
+ final tagName = json['tag_name'] as String;
+ final releaseName = json['name'] as String? ?? tagName;
+ final body = json['body'] as String? ?? '';
+
+ AppLogger.info('Parsing GitHub release: tag=$tagName, name=$releaseName, isNightly=$isNightly');
+ AppLogger.info('Release body preview: ${body.length > 100 ? '${body.substring(0, 100)}...' : body}');
+
+ // Find APK asset
+ Map? apkAsset;
+ if (json['assets'] != null && (json['assets'] as List).isNotEmpty) {
+ apkAsset = (json['assets'] as List).firstWhere(
+ (asset) => asset['name'].toString().endsWith('.apk'),
+ orElse: () => null,
+ ) as Map?;
+ }
+
+ final apkUrl = apkAsset?['browser_download_url'] as String? ?? '';
+ final apkFileName = apkUrl.isNotEmpty ? Uri.parse(apkUrl).pathSegments.last : '';
+ final fileSizeBytes = apkAsset?['size'] as int? ?? 0;
+
+ AppLogger.info('Found APK asset: $apkFileName ($fileSizeBytes bytes)');
+
+ // Parse release date
+ DateTime buildDate;
+ try {
+ buildDate = DateTime.parse(json['published_at'] as String);
+ } catch (_) {
+ buildDate = DateTime.now();
+ }
+
+ // For nightly builds, extract version and build number from the tag name or release body
+ String version;
+ String buildNumber = '';
+
+ if (isNightly && tagName.startsWith('nightly-')) {
+ AppLogger.info('Processing nightly release...');
+
+ // Look for version information in the release body with improved regex patterns
+ RegExp versionRegex = RegExp(r'\*\*Version\*\*:\s*`?([^`\s\n]+)`?', caseSensitive: false);
+ RegExpMatch? versionMatch = versionRegex.firstMatch(body);
+
+ // Try alternative patterns if the first one doesn't match
+ if (versionMatch == null) {
+ versionRegex = RegExp(r'Version[:\s]*`?([^`\s\n]+)`?', caseSensitive: false);
+ versionMatch = versionRegex.firstMatch(body);
+ }
+
+ if (versionMatch != null) {
+ version = versionMatch.group(1)!.trim();
+ AppLogger.info('Parsed nightly version from release body: $version');
+
+ // Extract build number from version string (after +)
+ final buildMatch = RegExp(r'\+(\d+)').firstMatch(version);
+ if (buildMatch != null) {
+ buildNumber = buildMatch.group(1) ?? '';
+ AppLogger.info('Extracted build number: $buildNumber');
+ }
+ } else {
+ // Fallback: use tag name as version
+ version = tagName;
+ AppLogger.info('Using tag name as version fallback: $version');
+ }
+ } else {
+ // For regular/stable releases
+ AppLogger.info('Processing stable release...');
+ version = tagName.startsWith('v') ? tagName.substring(1) : tagName;
+ AppLogger.info('Initial version from tag: $version');
+
+ // Look for version information in the release body
+ RegExp versionRegex = RegExp(r'Version[:\s]*`?([^`\s\n]+)`?', caseSensitive: false);
+ RegExpMatch? versionMatch = versionRegex.firstMatch(body);
+
+ if (versionMatch != null) {
+ final bodyVersion = versionMatch.group(1)!.trim();
+ AppLogger.info('Found version in release body: $bodyVersion');
+ // Use body version if it's more detailed than tag name
+ if (bodyVersion.length > version.length) {
+ version = bodyVersion;
+ AppLogger.info('Using body version instead of tag: $version');
+ }
+ }
+
+ // Look for build number in the release body
+ RegExp buildRegex = RegExp(r'Build Number[:\s]*`?(\d+)`?', caseSensitive: false);
+ RegExpMatch? buildMatch = buildRegex.firstMatch(body);
+
+ if (buildMatch != null) {
+ buildNumber = buildMatch.group(1) ?? '';
+ AppLogger.info('Found build number in release body: $buildNumber');
+ } else {
+ // Fallback: try to extract from release name
+ final fallbackBuildMatch = RegExp(r'build\s*(\d+)', caseSensitive: false).firstMatch(releaseName);
+ if (fallbackBuildMatch != null) {
+ buildNumber = fallbackBuildMatch.group(1) ?? '';
+ AppLogger.info('Found build number in release name: $buildNumber');
+ } else {
+ AppLogger.info('No build number found, using empty string');
+ }
+ }
+
+ AppLogger.info('Parsed stable release: version=$version, buildNumber=$buildNumber');
+ }
+
+ return UpdateInfo(
+ version: version,
+ buildNumber: buildNumber,
+ buildDate: buildDate,
+ apkUrl: apkUrl,
+ apkFileName: apkFileName,
+ changelog: json['body'] as String?,
+ isNightly: isNightly,
+ fileSizeBytes: fileSizeBytes,
+ );
+ }
+}
+
+class AppVersionInfo {
+ final String version;
+ final String buildNumber;
+
+ AppVersionInfo({
+ required this.version,
+ required this.buildNumber,
+ });
+}
+
+class UpdateCheckResult {
+ final bool hasUpdate;
+ final bool isNightly;
+ final UpdateInfo? updateInfo;
+ final String? error;
+
+ UpdateCheckResult({
+ required this.hasUpdate,
+ required this.isNightly,
+ required this.updateInfo,
+ this.error,
+ });
+}
+
+class UpdateDownloadResult {
+ final bool success;
+ final String? filePath;
+ final UpdateInfo? updateInfo;
+ final String? error;
+
+ UpdateDownloadResult({
+ required this.success,
+ required this.filePath,
+ this.updateInfo,
+ this.error,
+ });
+}
+
+class DownloadProgress {
+ final int downloadedBytes;
+ final int totalBytes;
+ final double progress;
+ final double speedBytesPerSecond;
+ final double estimatedRemainingSeconds;
+
+ DownloadProgress({
+ required this.downloadedBytes,
+ required this.totalBytes,
+ required this.progress,
+ required this.speedBytesPerSecond,
+ required this.estimatedRemainingSeconds,
+ });
+}
+
+class _DownloadProgressDialog extends StatefulWidget {
+ final UpdateInfo updateInfo;
+
+ const _DownloadProgressDialog({required this.updateInfo});
+
+ @override
+ _DownloadProgressDialogState createState() => _DownloadProgressDialogState();
+}
+
+class _DownloadProgressDialogState extends State<_DownloadProgressDialog> {
+ late Future _downloadFuture;
+ final StreamController _progressController = StreamController();
+ DownloadProgress? _currentProgress;
+
+ @override
+ void initState() {
+ super.initState();
+ _startDownload();
+ }
+
+ void _startDownload() {
+ _downloadFuture = UpdateService.downloadUpdate(
+ widget.updateInfo,
+ onProgress: (progress) {
+ if (!_progressController.isClosed) {
+ _progressController.add(progress);
+ }
+ },
+ );
+
+ // Handle download completion
+ _downloadFuture.then((result) {
+ if (mounted) {
+ if (result.success) {
+ Navigator.of(context).pop(result.filePath);
+ } else {
+ Navigator.of(context).pop(null);
+ }
+ }
+ }).catchError((error) {
+ if (mounted) {
+ Navigator.of(context).pop(null);
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _progressController.close();
+ super.dispose();
+ }
+
+ String _formatBytes(int bytes) {
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+ return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
+ }
+
+ String _formatSpeed(double bytesPerSecond) {
+ // Convert bytes/s to megabits/s (1 byte = 8 bits, then divide by 1,000,000 for mega)
+ final megabitsPerSecond = (bytesPerSecond * 8) / 1000000;
+ return '${megabitsPerSecond.toStringAsFixed(1)} Mbps';
+ }
+
+ String _formatTime(double seconds) {
+ if (seconds < 60) return '${seconds.toStringAsFixed(0)}s';
+ if (seconds < 3600) return '${(seconds / 60).toStringAsFixed(0)}m ${(seconds % 60).toStringAsFixed(0)}s';
+ return '${(seconds / 3600).toStringAsFixed(0)}h ${((seconds % 3600) / 60).toStringAsFixed(0)}m';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: Row(
+ children: [
+ Icon(
+ widget.updateInfo.isNightly ? Icons.science : Icons.system_update,
+ color: widget.updateInfo.isNightly ? Colors.orange : Colors.blue,
+ ),
+ const SizedBox(width: 8),
+ const Text('Downloading Update'),
+ ],
+ ),
+ content: SizedBox(
+ width: 300,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Version: ${widget.updateInfo.version}',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ Text(
+ 'File: ${widget.updateInfo.apkFileName}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 16),
+
+ StreamBuilder(
+ stream: _progressController.stream,
+ builder: (context, snapshot) {
+ if (snapshot.hasData) {
+ _currentProgress = snapshot.data!;
+ }
+
+ if (_currentProgress == null) {
+ return Column(
+ children: [
+ const Center(child: CircularProgressIndicator()),
+ const SizedBox(height: 16),
+ const Center(child: Text('Initializing download...')),
+ ],
+ );
+ }
+
+ final progress = _currentProgress!;
+ final progressPercent = (progress.progress * 100).toStringAsFixed(1);
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Progress bar
+ LinearProgressIndicator(
+ value: progress.progress,
+ backgroundColor: Colors.grey[300],
+ valueColor: AlwaysStoppedAnimation(
+ widget.updateInfo.isNightly ? Colors.orange : Colors.blue,
+ ),
+ ),
+ const SizedBox(height: 12),
+
+ // Progress percentage and size
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ '$progressPercent%',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ '${_formatBytes(progress.downloadedBytes)} / ${_formatBytes(progress.totalBytes)}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+
+ // Download speed
+ Row(
+ children: [
+ const Icon(Icons.speed, size: 16, color: Colors.grey),
+ const SizedBox(width: 4),
+ Text(
+ 'Speed: ${_formatSpeed(progress.speedBytesPerSecond)}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+
+ // Estimated time remaining
+ if (progress.estimatedRemainingSeconds > 0) ...[
+ Row(
+ children: [
+ const Icon(Icons.schedule, size: 16, color: Colors.grey),
+ const SizedBox(width: 4),
+ Text(
+ 'Time remaining: ${_formatTime(progress.estimatedRemainingSeconds)}',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ],
+ ],
+ );
+ },
+ ),
+
+ const SizedBox(height: 16),
+
+ // Download info
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.info_outline,
+ size: 16,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'The update will be installed automatically when download completes.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ FutureBuilder(
+ future: _downloadFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done) {
+ if (snapshot.hasError || (snapshot.data != null && !snapshot.data!.success)) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(null),
+ child: const Text('Close'),
+ ),
+ const SizedBox(width: 8),
+ ElevatedButton(
+ onPressed: () {
+ setState(() {
+ _startDownload();
+ });
+ },
+ child: const Text('Retry'),
+ ),
+ ],
+ );
+ }
+ }
+
+ return TextButton(
+ onPressed: null, // Disable cancel during download for now
+ child: const Text('Cancel'),
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart
index a3add65..3f2617a 100644
--- a/lib/services/widget_service.dart
+++ b/lib/services/widget_service.dart
@@ -2,11 +2,13 @@ import 'package:home_widget/home_widget.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'dart:async';
+import 'dart:math' as math;
import '../models/activity.dart';
import '../models/user.dart';
+import 'app_logger.dart';
@pragma('vm:entry-point')
-class WidgetService { static const String _widgetName = 'PlaytivityWidget';
+class WidgetService {
static const String _androidWidgetName = 'com.mliem.playtivity.widget.PlaytivityWidgetReceiver';
static const String _iOSWidgetName = 'PlaytivityWidget';
@@ -14,6 +16,7 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
static const MethodChannel _channel = MethodChannel('playtivity_widget');
// Initialize the widget
+ @pragma('vm:entry-point')
static Future initialize() async {
await HomeWidget.setAppGroupId('group.com.mliem.playtivity');
// Register interactive callback for widget clicks
@@ -23,53 +26,46 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
// Background callback for interactive widget actions
@pragma("vm:entry-point")
static FutureOr backgroundCallback(Uri? data) async {
- print('๐ฏ Widget callback triggered with data: $data');
-
if (data != null) {
final action = data.host;
- final queryParams = data.queryParameters;
switch (action) {
case 'openApp':
- print('๐ฑ Opening main app from widget');
- break;
case 'refreshData':
- print('๐ Refreshing widget data');
- // Could trigger a data refresh here
break;
default:
- print('๐คท Unknown widget action: $action');
+ AppLogger.warning('Unknown widget action: $action');
}
}
}
-
+
// Save data directly to SharedPreferences as fallback
static Future _saveToSharedPreferences(String key, String value) async {
try {
final prefs = await SharedPreferences.getInstance();
+ // Save to both flutter prefix and regular HomeWidget prefix for maximum compatibility
await prefs.setString('flutter.$key', value);
- print('๐ Direct save: flutter.$key = $value');
+ await prefs.setString(key, value); // Also save without flutter prefix
} catch (e) {
- print('โ Error saving to SharedPreferences: $e');
+ AppLogger.error('Error saving to SharedPreferences', e);
}
}
// Update widget with friends' activities only
+ @pragma('vm:entry-point')
static Future updateWidget({
User? currentUser,
List? friendsActivities,
}) async {
try {
-
// Save friends' activities (show all activities, no longer limited to 5)
if (friendsActivities != null && friendsActivities.isNotEmpty) {
// Remove the take(5) limitation to show all friends
final activities = friendsActivities.toList();
- print('๐ Widget: Saving ${activities.length} activities');
-
- // First, clear any old data by clearing up to 50 slots to handle large friend lists
- for (int i = 0; i < 50; i++) {
+ // Optimize: Only clear necessary slots based on current activity count
+ final maxSlotsToProcess = math.max(activities.length, 10); // Process at least 10 to clear old data
+ for (int i = 0; i < maxSlotsToProcess; i++) {
await HomeWidget.saveWidgetData('friend_${i}_name', '');
await HomeWidget.saveWidgetData('friend_${i}_track', '');
await HomeWidget.saveWidgetData('friend_${i}_artist', '');
@@ -94,32 +90,25 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
// Now save all the actual activities
for (int i = 0; i < activities.length; i++) {
final activity = activities[i];
- print('๐ Widget: Activity $i - ${activity.user.displayName}: ${activity.contentName}');
- // Save via HomeWidget
+ // Save via HomeWidget - batch operations for better performance
await HomeWidget.saveWidgetData('friend_${i}_name', activity.user.displayName);
await HomeWidget.saveWidgetData('friend_${i}_track', activity.contentName);
await HomeWidget.saveWidgetData('friend_${i}_artist', activity.contentSubtitle);
await HomeWidget.saveWidgetData('friend_${i}_album_art', activity.contentImageUrl ?? '');
- // Note: friend_image saved for potential future iOS widget support
await HomeWidget.saveWidgetData('friend_${i}_image', activity.user.imageUrl ?? '');
- // Save friend user ID for Spotify profile launching
await HomeWidget.saveWidgetData('friend_${i}_user_id', activity.user.id);
- // Save timestamp and currently playing status for "time ago" functionality
await HomeWidget.saveWidgetData('friend_${i}_timestamp', activity.timestamp.millisecondsSinceEpoch.toString());
await HomeWidget.saveWidgetData('friend_${i}_is_currently_playing', activity.isCurrentlyPlaying.toString());
await HomeWidget.saveWidgetData('friend_${i}_activity_type', activity.type == ActivityType.playlist ? 'playlist' : 'track');
- // Save directly to SharedPreferences as fallback
+ // Save directly to SharedPreferences as fallback - reduced logging
await _saveToSharedPreferences('friend_${i}_name', activity.user.displayName);
await _saveToSharedPreferences('friend_${i}_track', activity.contentName);
await _saveToSharedPreferences('friend_${i}_artist', activity.contentSubtitle);
await _saveToSharedPreferences('friend_${i}_album_art', activity.contentImageUrl ?? '');
- // Note: Android Glance widgets don't support network images, but saving for iOS
await _saveToSharedPreferences('friend_${i}_image', activity.user.imageUrl ?? '');
- // Save friend user ID for Spotify profile launching
await _saveToSharedPreferences('friend_${i}_user_id', activity.user.id);
- // Save timestamp and currently playing status for "time ago" functionality
await _saveToSharedPreferences('friend_${i}_timestamp', activity.timestamp.millisecondsSinceEpoch.toString());
await _saveToSharedPreferences('friend_${i}_is_currently_playing', activity.isCurrentlyPlaying.toString());
await _saveToSharedPreferences('friend_${i}_activity_type', activity.type == ActivityType.playlist ? 'playlist' : 'track');
@@ -128,19 +117,13 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
// Save activity count AFTER all activities are saved for atomic updates
await HomeWidget.saveWidgetData('activities_count', activities.length.toString());
await _saveToSharedPreferences('activities_count', activities.length.toString());
- print('๐ Widget: Saved activities_count as ${activities.length} AFTER saving all activities');
-
- // Extra debug logging
- print('๐ Widget: Successfully saved all ${activities.length} activities');
- print('๐ Widget: First friend saved: ${activities.isNotEmpty ? activities[0].user.displayName : 'none'}');
- print('๐ Widget: Last friend saved: ${activities.isNotEmpty ? activities[activities.length - 1].user.displayName : 'none'}');
-
} else {
- print('๐ Widget: No activities to save');
// No activities - clear all slots and set count to 0
await HomeWidget.saveWidgetData('activities_count', '0');
await _saveToSharedPreferences('activities_count', '0');
- for (int i = 0; i < 50; i++) {
+
+ // Clear only necessary slots for performance
+ for (int i = 0; i < 10; i++) {
await HomeWidget.saveWidgetData('friend_${i}_name', '');
await HomeWidget.saveWidgetData('friend_${i}_track', '');
await HomeWidget.saveWidgetData('friend_${i}_artist', '');
@@ -163,27 +146,30 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
}
}
- // Save last update timestamp
- await HomeWidget.saveWidgetData('last_update', DateTime.now().toIso8601String());
await _saveToSharedPreferences('last_update', DateTime.now().toIso8601String());
- // Longer delay to ensure all data is persisted before widget update
- await Future.delayed(const Duration(milliseconds: 500));
-
- print('๐ Widget: About to call updateWidget()');
- print('๐ Widget: Using androidName: $_androidWidgetName');
+ // Reduced delay for faster widget updates
+ await Future.delayed(const Duration(milliseconds: 100));
- // Trigger widget update via method channel
+ // Trigger simple widget update
try {
- final result = await _channel.invokeMethod('updateWidget');
- print('๐ฑ Widget update triggered via method channel: $result');
- } catch (channelError) {
- print('โ Method channel widget update failed: $channelError');
- // Fallback: data is still saved, widget may update on next system refresh
- print('๐ฑ Data saved to SharedPreferences - widget will update on next refresh');
+ await HomeWidget.updateWidget(
+ qualifiedAndroidName: _androidWidgetName,
+ iOSName: _iOSWidgetName,
+ );
+ // Force image caching after a short delay
+ Future.delayed(const Duration(milliseconds: 500), () async {
+ try {
+ await _channel.invokeMethod('cacheImages');
+ } catch (e) {
+ AppLogger.error('Image caching failed', e);
+ }
+ });
+ } catch (e) {
+ AppLogger.error('Widget update failed', e);
}
} catch (e) {
- print('โ Error updating widget: $e');
+ AppLogger.error('Error updating widget', e);
}
}
@@ -209,8 +195,8 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
await HomeWidget.saveWidgetData('activities_count', '0');
await HomeWidget.saveWidgetData('last_update', '');
- // Clear up to 50 friend slots to ensure all data is cleared
- for (int i = 0; i < 50; i++) {
+ // Optimized: Clear only necessary slots to improve performance
+ for (int i = 0; i < 20; i++) {
await HomeWidget.saveWidgetData('friend_${i}_name', '');
await HomeWidget.saveWidgetData('friend_${i}_track', '');
await HomeWidget.saveWidgetData('friend_${i}_artist', '');
@@ -223,51 +209,116 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget';
}
await HomeWidget.updateWidget(
- androidName: _androidWidgetName,
+ qualifiedAndroidName: _androidWidgetName,
iOSName: _iOSWidgetName,
);
} catch (e) {
- print('โ Error clearing widget data: $e');
+ AppLogger.error('Error clearing widget data', e);
}
}
-
+
// Debug method to test widget data and update
static Future debugWidgetData() async {
try {
- print('๐ === WIDGET DEBUG TEST ===');
+ AppLogger.widget('=== WIDGET DEBUG TEST ===');
// Check what's currently in SharedPreferences
final prefs = await SharedPreferences.getInstance();
- print('๐ Current SharedPreferences data:');
+ AppLogger.widget('Current SharedPreferences data:');
final activitiesCount = prefs.getString('flutter.activities_count') ?? 'null';
- print('๐ activities_count: $activitiesCount');
+ AppLogger.widget('activities_count: $activitiesCount');
- // Debug all activities (up to 20 to avoid spam)
+ // Debug only first 5 activities to reduce noise
final count = int.tryParse(activitiesCount) ?? 0;
- final maxDebug = count > 20 ? 20 : count;
+ final maxDebug = count > 5 ? 5 : count;
for (int i = 0; i < maxDebug; i++) {
final name = prefs.getString('flutter.friend_${i}_name') ?? 'null';
final track = prefs.getString('flutter.friend_${i}_track') ?? 'null';
final artist = prefs.getString('flutter.friend_${i}_artist') ?? 'null';
- print('๐ friend_$i: $name - $track by $artist');
+ final userId = prefs.getString('flutter.friend_${i}_user_id') ?? 'null';
+ AppLogger.widget('friend_$i: $name - $track by $artist (ID: $userId)');
}
- if (count > 20) {
- print('๐ ... and ${count - 20} more activities');
+ if (count > 5) {
+ AppLogger.widget('... and ${count - 5} more activities');
}
// Test widget update via method channel
- print('๐ Testing widget update...');
+ AppLogger.widget('Testing widget update...');
try {
final result = await _channel.invokeMethod('updateWidget');
- print('๐ Widget update successful via method channel: $result');
+ AppLogger.widget('Widget update successful via method channel: $result');
} catch (e) {
- print('๐ Widget update failed: $e');
+ AppLogger.error('Widget update failed', e);
}
- print('๐ === END WIDGET DEBUG TEST ===');
+ AppLogger.widget('=== END WIDGET DEBUG TEST ===');
} catch (e) {
- print('โ Error in widget debug: $e');
+ AppLogger.error('Error in widget debug', e);
}
}
+
+ // Comprehensive debug for release builds
+ static Future