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> debugReleaseWidget() async { + final debugInfo = {}; + + try { + AppLogger.widget('=== RELEASE WIDGET DEBUG ==='); + + // 1. Check SharedPreferences data + final prefs = await SharedPreferences.getInstance(); + final allKeys = prefs.getKeys().toList(); + final widgetKeys = allKeys.where((key) => + key.contains('activities') || key.contains('friend_') || key.contains('last_update') + ).toList(); + + debugInfo['total_keys'] = allKeys.length; + debugInfo['widget_keys'] = widgetKeys.length; + debugInfo['widget_key_list'] = widgetKeys; + + AppLogger.widget('Total keys in SharedPreferences: ${allKeys.length}'); + AppLogger.widget('Widget-related keys: ${widgetKeys.length}'); + + // 2. Check activities data + final flutterActivitiesCount = prefs.getString('flutter.activities_count'); + final homeWidgetActivitiesCount = prefs.getString('activities_count'); + + debugInfo['flutter_activities_count'] = flutterActivitiesCount; + debugInfo['home_widget_activities_count'] = homeWidgetActivitiesCount; + + AppLogger.widget('Flutter activities count: $flutterActivitiesCount'); + AppLogger.widget('HomeWidget activities count: $homeWidgetActivitiesCount'); + + // 3. Test method channel + debugInfo['method_channel_available'] = false; + try { + final result = await _channel.invokeMethod('updateWidget'); + debugInfo['method_channel_available'] = true; + debugInfo['method_channel_result'] = result; + AppLogger.widget('Method channel works: $result'); + } catch (e) { + debugInfo['method_channel_error'] = e.toString(); + AppLogger.error('Method channel failed', e); + } + + // 4. Test HomeWidget integration + debugInfo['home_widget_available'] = false; + try { + await HomeWidget.setAppGroupId('group.com.mliem.playtivity'); + debugInfo['home_widget_available'] = true; + AppLogger.widget('HomeWidget integration works'); + } catch (e) { + debugInfo['home_widget_error'] = e.toString(); + AppLogger.error('HomeWidget integration failed', e); + } + + AppLogger.widget('=== END RELEASE DEBUG ==='); + + } catch (e) { + debugInfo['debug_error'] = e.toString(); + AppLogger.error('Debug error', e); + } + + return debugInfo; + } } \ No newline at end of file diff --git a/lib/utils/auth_utils.dart b/lib/utils/auth_utils.dart index 576815d..87ecc15 100644 --- a/lib/utils/auth_utils.dart +++ b/lib/utils/auth_utils.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; import '../widgets/spotify_webview_login.dart'; +import '../services/app_logger.dart'; class AuthUtils { /// Shows a re-authentication dialog when authentication expires @@ -33,29 +34,28 @@ class AuthUtils { /// Handles re-authentication flow static Future handleReAuthentication(BuildContext context) async { final authProvider = context.read(); - - try { - print('๐Ÿ”„ Starting re-authentication flow...'); + try { + AppLogger.auth('Starting re-authentication flow...'); // Clear old authentication data await authProvider.logout(); // Show WebView login + if (!context.mounted) return false; final result = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => SpotifyWebViewLogin( onAuthComplete: (bearerToken, headers) async { - print('๐Ÿ”„ Re-authentication completed, processing...'); + AppLogger.auth('Re-authentication completed, processing...'); try { await authProvider.handleAuthComplete(bearerToken, headers); - print('โœ… Re-authentication successful'); + AppLogger.auth('Re-authentication successful'); // Pop the WebView if (context.mounted && Navigator.canPop(context)) { Navigator.of(context).pop(true); - } - } catch (e) { - print('โŒ Error in re-authentication: $e'); + } } catch (e) { + AppLogger.error('Error in re-authentication', e); if (context.mounted) { Navigator.of(context).pop(false); ScaffoldMessenger.of(context).showSnackBar( @@ -72,11 +72,10 @@ class AuthUtils { }, ), ), - ); - + ); return result == true; } catch (e) { - print('โŒ Error during re-authentication flow: $e'); + AppLogger.error('Error during re-authentication flow', e); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -92,9 +91,8 @@ class AuthUtils { /// Checks authentication and shows re-auth dialog if needed static Future ensureAuthenticated(BuildContext context) async { final authProvider = context.read(); - - if (!authProvider.isAuthenticated) { - print('โš ๏ธ Authentication required'); + if (!authProvider.isAuthenticated) { + AppLogger.auth('Authentication required'); final shouldReAuth = await showReAuthDialog(context); if (shouldReAuth && context.mounted) { @@ -109,13 +107,19 @@ class AuthUtils { /// Handles 401/403 errors by immediately navigating to login screen /// This should be called when authentication errors are detected static Future handleAuthenticationError(BuildContext context, {String? errorMessage}) async { - print('๐Ÿšจ Authentication error detected: ${errorMessage ?? "401/403 error"}'); + AppLogger.auth('Authentication error detected: ${errorMessage ?? "401/403 error"}'); try { final authProvider = context.read(); - // Force logout and navigate to login screen - await authProvider.forceLogoutAndNavigate(context); + // First, try to reset authentication state without navigation + // This clears all cached data and tokens but keeps the user in the app + await authProvider.resetAuthenticationState(); + + // If we're in a context where we can navigate, force logout and navigate + if (context.mounted) { + await authProvider.forceLogoutAndNavigate(context); + } // Show a brief message about the authentication error if (context.mounted && errorMessage != null) { @@ -128,7 +132,7 @@ class AuthUtils { ); } } catch (e) { - print('โŒ Error handling authentication error: $e'); + AppLogger.error('Error handling authentication error', e); // Fallback: try to navigate directly if (context.mounted) { diff --git a/lib/utils/friend_profile_launcher.dart b/lib/utils/friend_profile_launcher.dart new file mode 100644 index 0000000..8c038a0 --- /dev/null +++ b/lib/utils/friend_profile_launcher.dart @@ -0,0 +1,68 @@ +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/app_logger.dart'; + +class FriendProfileLauncher { + static const MethodChannel _channel = MethodChannel('playtivity_widget'); + + /// Open a friend's Spotify profile + static Future openFriendProfile(String userId, {String? friendName}) async { + try { + AppLogger.spotify('Opening friend profile: $friendName (ID: $userId)'); + + // Try using the native Android method first (for better integration) + try { + await _channel.invokeMethod('openFriendProfile', { + 'userId': userId, + 'friendName': friendName, + }); + AppLogger.spotify('Opened friend profile via native Android method'); + return true; + } catch (e) { + AppLogger.warning('Native method failed, falling back to direct URL launch: $e'); + } + + // Fallback to direct URL launching + return await _launchSpotifyProfile(userId); + } catch (e) { + AppLogger.error('Error opening friend profile', e); + return false; + } + } + + /// Direct method to launch Spotify profile using URL launcher + static Future _launchSpotifyProfile(String userId) async { + try { + // Try Spotify app URI first + final spotifyUri = Uri.parse('spotify:user:$userId'); + if (await canLaunchUrl(spotifyUri)) { + final success = await launchUrl( + spotifyUri, + mode: LaunchMode.externalApplication, + ); + if (success) { + AppLogger.spotify('Successfully opened Spotify profile via app: $userId'); + return true; + } + } + + // Fallback to web URL + final webUrl = Uri.parse('https://open.spotify.com/user/$userId'); + if (await canLaunchUrl(webUrl)) { + final success = await launchUrl( + webUrl, + mode: LaunchMode.externalApplication, + ); + if (success) { + AppLogger.spotify('Successfully opened Spotify profile via web: $userId'); + return true; + } + } + + return false; + } catch (e) { + AppLogger.error('Error launching Spotify profile directly', e); + return false; + } + } +} \ No newline at end of file diff --git a/lib/utils/spotify_launcher.dart b/lib/utils/spotify_launcher.dart index 25507f6..23a370d 100644 --- a/lib/utils/spotify_launcher.dart +++ b/lib/utils/spotify_launcher.dart @@ -1,11 +1,12 @@ import 'package:url_launcher/url_launcher.dart'; +import '../services/app_logger.dart'; class SpotifyLauncher { /// Launch a Spotify URI in the Spotify app with play action /// This will attempt to start playing the content immediately static Future launchSpotifyUriAndPlay(String uri) async { try { - print('๐ŸŽต Attempting to PLAY: $uri'); + AppLogger.spotify('Attempting to PLAY: $uri'); // For play action, always try the Spotify app first with the native URI final spotifyUri = Uri.parse(uri); @@ -15,7 +16,7 @@ class SpotifyLauncher { mode: LaunchMode.externalApplication, ); if (success) { - print('โœ… Successfully launched and played Spotify URI: $uri'); + AppLogger.spotify('Successfully launched and played Spotify URI: $uri'); } return success; } @@ -29,14 +30,14 @@ class SpotifyLauncher { mode: LaunchMode.externalApplication, ); if (success) { - print('โœ… Successfully launched Spotify web URL for play: $webUrl'); + AppLogger.spotify('Successfully launched Spotify web URL for play: $webUrl'); } return success; } return false; } catch (e) { - print('โŒ Error launching Spotify URI with play: $e'); + AppLogger.error('Error launching Spotify URI with play', e); return false; } } @@ -45,7 +46,7 @@ class SpotifyLauncher { /// Uses web URLs to avoid auto-play behavior in the Spotify app static Future launchSpotifyUri(String uri) async { try { - print('๐Ÿ”— Attempting to NAVIGATE to: $uri'); + AppLogger.spotify('Attempting to NAVIGATE to: $uri'); // For navigation, prefer web URLs to avoid auto-play behavior final webUrl = _convertUriToWebUrl(uri); @@ -57,7 +58,7 @@ class SpotifyLauncher { mode: LaunchMode.externalApplication, ); if (success) { - print('โœ… Successfully navigated to Spotify web URL: $webUrl'); + AppLogger.spotify('Successfully navigated to Spotify web URL: $webUrl'); return success; } } @@ -71,14 +72,14 @@ class SpotifyLauncher { mode: LaunchMode.externalApplication, ); if (success) { - print('โœ… Successfully navigated to Spotify URI: $uri'); + AppLogger.spotify('Successfully navigated to Spotify URI: $uri'); } return success; } return false; } catch (e) { - print('โŒ Error launching Spotify URI: $e'); + AppLogger.error('Error launching Spotify URI', e); return false; } } @@ -120,4 +121,4 @@ class SpotifyLauncher { } return null; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index a9e19bd..01a64e6 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -22,7 +22,7 @@ class AppTheme { primaryColor: spotifyGreen, scaffoldBackgroundColor: spotifyWhite, appBarTheme: AppBarTheme( - backgroundColor: spotifyWhite.withOpacity(0.85), // Transparent effect + backgroundColor: spotifyWhite.withValues(alpha: 0.85), // Transparent effect foregroundColor: spotifyBlack, elevation: 0, scrolledUnderElevation: 0, @@ -30,7 +30,7 @@ class AppTheme { surfaceTintColor: Colors.transparent, ), bottomNavigationBarTheme: BottomNavigationBarThemeData( - backgroundColor: spotifyWhite.withOpacity(0.85), // Transparent effect + backgroundColor: spotifyWhite.withValues(alpha: 0.85), // Transparent effect selectedItemColor: spotifyGreen, unselectedItemColor: lightTextSecondary, type: BottomNavigationBarType.fixed, @@ -45,13 +45,13 @@ class AppTheme { ), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), elevation: 2, - shadowColor: spotifyGreen.withOpacity(0.3), + shadowColor: spotifyGreen.withValues(alpha: 0.3), ), ), cardTheme: CardThemeData( color: lightCardBackground, elevation: 2, - shadowColor: Colors.black.withOpacity(0.1), + shadowColor: Colors.black.withValues(alpha: 0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -76,7 +76,7 @@ class AppTheme { primaryColor: spotifyGreen, scaffoldBackgroundColor: spotifyBlack, appBarTheme: AppBarTheme( - backgroundColor: spotifyBlack.withOpacity(0.85), // Transparent effect + backgroundColor: spotifyBlack.withValues(alpha: 0.9), // Increased opacity for better visibility in dark mode foregroundColor: spotifyWhite, elevation: 0, scrolledUnderElevation: 0, @@ -84,7 +84,7 @@ class AppTheme { surfaceTintColor: Colors.transparent, ), bottomNavigationBarTheme: BottomNavigationBarThemeData( - backgroundColor: spotifyBlack.withOpacity(0.85), // Transparent effect + backgroundColor: spotifyBlack.withValues(alpha: 0.85), // Transparent effect selectedItemColor: spotifyGreen, unselectedItemColor: spotifyLightGray, type: BottomNavigationBarType.fixed, diff --git a/lib/utils/update_launcher.dart b/lib/utils/update_launcher.dart new file mode 100644 index 0000000..ec19031 --- /dev/null +++ b/lib/utils/update_launcher.dart @@ -0,0 +1,142 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/app_logger.dart'; + +/// Utility class to handle launching APK installation +/// and other update-related functionality. +class UpdateLauncher { + /// Platform channel for native APK installation + static const platform = MethodChannel('com.mliem.playtivity/update_launcher'); + + /// Check if the app has permission to install packages + static Future canInstallPackages() async { + try { + if (!Platform.isAndroid) { + return false; + } + + final result = await platform.invokeMethod('canInstallPackages'); + return result == true; + } catch (e) { + AppLogger.error('Error checking install permission', e); + return false; + } + } + + /// Request permission to install packages (Android 8.0+) + static Future requestInstallPermission() async { + try { + if (!Platform.isAndroid) { + return false; + } + + final result = await platform.invokeMethod('requestInstallPermission'); + AppLogger.info('Install permission request result: $result'); + return result == "PERMISSION_ALREADY_GRANTED" || result == "PERMISSION_NOT_REQUIRED"; + } catch (e) { + AppLogger.error('Error requesting install permission', e); + return false; + } + } + + /// Installs an APK file from the provided path. + /// + /// Returns true if the installation was started successfully. + /// Note: The app will be terminated by the Android system during installation. + static Future installApk(String filePath) async { + try { + if (!Platform.isAndroid) { + AppLogger.error('APK installation is only supported on Android'); + return false; + } + + // Check if the file exists + final file = File(filePath); + if (!await file.exists()) { + AppLogger.error('APK file does not exist: $filePath'); + return false; + } + + // Check file size to ensure it's not corrupted + final fileSize = await file.length(); + AppLogger.info('Installing APK from: $filePath ($fileSize bytes)'); + + if (fileSize < 1024 * 1024) { // Less than 1MB is suspicious for an APK + AppLogger.warning('APK file seems too small: $fileSize bytes'); + } + + // Check file permissions + final stat = await file.stat(); + AppLogger.info('File permissions: ${stat.mode}, modified: ${stat.modified}'); + + // For Android, try multiple approaches + Exception? lastException; + + // Approach 1: Try using the platform channel first + try { + AppLogger.info('Attempting installation via platform channel...'); + await platform.invokeMethod('installApk', {'filePath': filePath}); + AppLogger.info('Platform channel installation initiated successfully'); + return true; + } on PlatformException catch (e) { + lastException = e; + AppLogger.warning('Platform channel failed: $e.message (Code: $e.code)'); + AppLogger.info('Details: $e.details'); + } catch (e) { + lastException = Exception('Platform channel error: $e'); + AppLogger.error('Unexpected platform channel error', e); + } + + // Approach 2: Fallback to URL launcher + try { + AppLogger.info('Attempting installation via URL launcher...'); + final uri = Uri.file(filePath); + final canLaunch = await canLaunchUrl(uri); + + if (!canLaunch) { + AppLogger.error('Cannot launch file URI: ${uri.toString()}'); + return false; + } + + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + + if (launched) { + AppLogger.info('URL launcher installation initiated successfully'); + return true; + } else { + AppLogger.error('URL launcher failed to launch'); + return false; + } + } catch (e) { + AppLogger.error('URL launcher error', e); + lastException = Exception('URL launcher error: $e'); + } + + // Approach 3: Final fallback - try Android package installer intent + try { + AppLogger.info('Attempting installation via direct intent...'); + await platform.invokeMethod('installApkDirect', {'filePath': filePath}); + AppLogger.info('Direct intent installation initiated successfully'); + return true; + } on PlatformException catch (e) { + AppLogger.warning('Direct intent failed: ${e.message}'); + } catch (e) { + AppLogger.error('Direct intent error', e); + } + + // All approaches failed + AppLogger.error('All installation approaches failed. Last error: $lastException'); + return false; + + } catch (e) { + AppLogger.error('Error installing APK', e); + return false; + } + } + + +} diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart new file mode 100644 index 0000000..c8954dc --- /dev/null +++ b/lib/utils/version_utils.dart @@ -0,0 +1,251 @@ +import 'package:flutter/foundation.dart'; + +/// Utility class for comparing version strings and handling +/// version-related functionality. +class VersionUtils { + /// Extract base version from any version string (removes nightly suffixes) + static String extractBaseVersion(String version) { + // Remove build metadata first + final withoutBuild = version.split('+')[0]; + + // If it contains nightly, extract everything before the first -nightly- + if (withoutBuild.contains('-nightly-')) { + return withoutBuild.split('-nightly-')[0]; + } + + return withoutBuild; + } + + /// Compares two version strings to determine if newVersion is newer than currentVersion. + /// + /// Versions are expected to be in format "x.y.z" or "x.y.z-suffix". + /// For example: "1.2.3" or "1.2.0-beta.4" + static bool isNewerVersion({ + required String currentVersion, + required String newVersion, + }) { + debugPrint('Comparing versions: current=$currentVersion, new=$newVersion'); + + // Handle nightly versions by checking if they are nightlies + if (currentVersion.contains('nightly') && newVersion.contains('nightly')) { + // For nightlies, we should use isNewerNightly + debugPrint('Both are nightly versions, use isNewerNightly instead'); + return false; + } else if (currentVersion.contains('nightly') && !newVersion.contains('nightly')) { + // If current is nightly but new is stable release, compare base versions + final currentBase = extractBaseVersion(currentVersion); + final newBase = extractBaseVersion(newVersion); + + debugPrint('Nightly vs Stable comparison:'); + debugPrint(' Current nightly: $currentVersion -> base: $currentBase'); + debugPrint(' New stable: $newVersion -> base: $newBase'); + + // Only prefer stable release if it has a genuinely higher base version + final baseVersionComparison = _compareBaseVersions(currentBase, newBase); + debugPrint(' Base version comparison result: $baseVersionComparison (positive = stable newer)'); + + final shouldUpdate = baseVersionComparison > 0; + debugPrint(' Should update from nightly to stable: $shouldUpdate'); + + return shouldUpdate; // Only true if stable has higher base version + } else if (!currentVersion.contains('nightly') && newVersion.contains('nightly')) { + // If current is release but new is nightly, don't update unless explicitly requested + debugPrint('Current is stable, new is nightly - not updating'); + return false; + } + + // Both are stable versions, do normal comparison + return _compareBaseVersions(currentVersion, newVersion) > 0; + } + + /// Compare base versions (without any suffixes) + static int _compareBaseVersions(String version1, String version2) { + // Strip any build metadata (anything after +) + final v1Clean = version1.split('+')[0]; + final v2Clean = version2.split('+')[0]; + + // Split versions into parts (split by periods or dashes) + final v1Parts = v1Clean.split('-'); + final v2Parts = v2Clean.split('-'); + + // Compare base version parts (x.y.z) + final v1BaseParts = v1Parts[0].split('.'); + final v2BaseParts = v2Parts[0].split('.'); + + // Normalize to have at least 3 parts (x.y.z) + while (v1BaseParts.length < 3) { + v1BaseParts.add('0'); + } + while (v2BaseParts.length < 3) { + v2BaseParts.add('0'); + } + + // Compare major.minor.patch parts + for (int i = 0; i < 3; i++) { + final v1Part = int.tryParse(v1BaseParts[i]) ?? 0; + final v2Part = int.tryParse(v2BaseParts[i]) ?? 0; + + if (v2Part > v1Part) return 1; // v2 is newer + if (v2Part < v1Part) return -1; // v1 is newer + } + + // Base versions are equal, check pre-release identifiers (e.g., -beta, -rc) + // A version with no pre-release part is greater than one with a pre-release part + if (v1Parts.length == 1 && v2Parts.length > 1) return -1; // v1 is newer (stable vs pre-release) + if (v1Parts.length > 1 && v2Parts.length == 1) return 1; // v2 is newer (stable vs pre-release) + + // If both have pre-release parts, compare them lexicographically + if (v1Parts.length > 1 && v2Parts.length > 1) { + return v2Parts[1].compareTo(v1Parts[1]); + } + + // Versions are equal + return 0; + } + + /// Specifically compares nightly build versions based on build date/time + /// + /// Nightly versions are expected to be in format "x.y.z-nightly-YYYYMMDD-HHMMSS" + static bool isNewerNightly({ + required String currentVersion, + required String newVersion, + required DateTime newBuildTime, + }) { + debugPrint('Comparing nightly versions:'); + debugPrint(' Current: $currentVersion'); + debugPrint(' New: $newVersion'); + debugPrint(' New build time: $newBuildTime'); + + // Extract the build timestamp from both versions + DateTime? currentBuildTime; + DateTime? newVersionBuildTime; + + try { + // Extract timestamps using regex + final nightlyRegex = RegExp(r'nightly-(\d{8})-(\d{6})'); + + // Extract current version timestamp + final currentMatches = nightlyRegex.allMatches(currentVersion); + if (currentMatches.isNotEmpty) { + final lastMatch = currentMatches.last; + final datePart = lastMatch.group(1)!; // YYYYMMDD + final timePart = lastMatch.group(2)!; // HHMMSS + + final year = int.parse(datePart.substring(0, 4)); + final month = int.parse(datePart.substring(4, 6)); + final day = int.parse(datePart.substring(6, 8)); + final hour = int.parse(timePart.substring(0, 2)); + final minute = int.parse(timePart.substring(2, 4)); + final second = int.parse(timePart.substring(4, 6)); + + currentBuildTime = DateTime(year, month, day, hour, minute, second); + debugPrint(' Parsed current build time: $currentBuildTime'); + } + + // Extract new version timestamp + final newMatches = nightlyRegex.allMatches(newVersion); + if (newMatches.isNotEmpty) { + final lastMatch = newMatches.last; + final datePart = lastMatch.group(1)!; // YYYYMMDD + final timePart = lastMatch.group(2)!; // HHMMSS + + final year = int.parse(datePart.substring(0, 4)); + final month = int.parse(datePart.substring(4, 6)); + final day = int.parse(datePart.substring(6, 8)); + final hour = int.parse(timePart.substring(0, 2)); + final minute = int.parse(timePart.substring(2, 4)); + final second = int.parse(timePart.substring(4, 6)); + + newVersionBuildTime = DateTime(year, month, day, hour, minute, second); + debugPrint(' Parsed new version build time: $newVersionBuildTime'); + } + } catch (e) { + debugPrint('Error parsing nightly version timestamps: $e'); + } + + // If we can't parse either timestamp, fall back to string comparison + if (currentBuildTime == null || newVersionBuildTime == null) { + debugPrint(' Could not parse build times, using string comparison'); + return isNewerVersion(currentVersion: currentVersion, newVersion: newVersion); + } + + // Compare build times with a threshold to avoid updates for very recent builds + final timeDifference = newVersionBuildTime.difference(currentBuildTime); + const minimumUpdateThreshold = Duration(minutes: 5); // Minimum 5 minutes difference + + final isNewer = timeDifference > minimumUpdateThreshold; + debugPrint(' Time difference: ${timeDifference.inMinutes} minutes'); + debugPrint(' Is newer: $isNewer'); + + return isNewer; + } + + /// Formats the version string in a human-readable format. + /// + /// For example: + /// - "1.2.3" -> "1.2.3" + /// - "1.2.0-beta.4" -> "1.2.0 Beta 4" + /// - "1.2.3-nightly-20250615-030600" -> "1.2.3 Nightly (Jun 15, 2025)" + static String formatVersion(String version) { + // Strip any build metadata (anything after +) + final clean = version.split('+')[0]; + + // Check if it's a nightly build + if (clean.contains('nightly')) { + try { + final parts = clean.split('-nightly-'); + final baseVersion = parts[0]; + final dateTimePart = parts.length > 1 ? parts[1] : ''; + + // Try to parse the date part + if (dateTimePart.length >= 8) { + final year = int.parse(dateTimePart.substring(0, 4)); + final month = int.parse(dateTimePart.substring(4, 6)); + final day = int.parse(dateTimePart.substring(6, 8)); + final date = DateTime(year, month, day); + return '$baseVersion Nightly (${_formatDate(date)})'; + } + + return '$baseVersion Nightly'; + } catch (e) { + return clean; + } + } + + // Handle other pre-release versions + final parts = clean.split('-'); + if (parts.length == 1) { + return clean; // Regular version with no suffix + } + + // Format pre-release part (e.g., beta.4 -> Beta 4) + final baseVersion = parts[0]; + final preReleaseParts = parts[1].split('.'); + + if (preReleaseParts.isEmpty) { + return clean; + } + + final preReleaseType = preReleaseParts[0].capitalize(); + if (preReleaseParts.length == 1) { + return '$baseVersion $preReleaseType'; + } + + return '$baseVersion $preReleaseType ${preReleaseParts[1]}'; + } + // Helper for formatting dates + static String _formatDate(DateTime date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + return '${months[date.month - 1]} ${date.day}, ${date.year}'; + } +} + +// Helper extension +extension StringExtension on String { + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} diff --git a/lib/widgets/activity_card.dart b/lib/widgets/activity_card.dart index 743c631..f8586bc 100644 --- a/lib/widgets/activity_card.dart +++ b/lib/widgets/activity_card.dart @@ -4,6 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import '../models/activity.dart'; import '../models/track.dart'; import '../utils/spotify_launcher.dart'; +import '../utils/friend_profile_launcher.dart'; +import 'package:playtivity/services/app_logger.dart'; class ActivityCard extends StatelessWidget { final Activity activity; @@ -29,7 +31,10 @@ class ActivityCard extends StatelessWidget { InkWell( onTap: () async { // Launch the user profile when avatar is tapped - await SpotifyLauncher.launchUser(activity.user.id); + await FriendProfileLauncher.openFriendProfile( + activity.user.id, + friendName: activity.user.displayName, + ); }, borderRadius: BorderRadius.circular(20), child: CircleAvatar( @@ -62,7 +67,10 @@ class ActivityCard extends StatelessWidget { InkWell( onTap: () async { // Launch the user profile when name is tapped - await SpotifyLauncher.launchUser(activity.user.id); + await FriendProfileLauncher.openFriendProfile( + activity.user.id, + friendName: activity.user.displayName, + ); }, borderRadius: BorderRadius.circular(4), child: Padding( @@ -247,7 +255,7 @@ class ActivityCard extends StatelessWidget { padding: const EdgeInsets.all(8), decoration: BoxDecoration( shape: BoxShape.circle, - color: Theme.of(context).primaryColor.withOpacity(0.1), + color: Theme.of(context).primaryColor.withAlpha(26), // 0.1 * 255 โ‰ˆ 26 ), child: Icon( Icons.play_arrow, @@ -291,12 +299,12 @@ class ActivityCard extends StatelessWidget { // Use the album URI directly from the track data if (track.albumUri != null && track.albumUri!.isNotEmpty) { await SpotifyLauncher.launchSpotifyUri(track.albumUri!); - print('โœ… Launched album: ${track.albumUri}'); + AppLogger.spotify('Launched album: ${track.albumUri}'); } else { - print('โš ๏ธ No album URI available, not launching anything'); + AppLogger.warning('No album URI available, not launching anything'); } } catch (e) { - print('โŒ Error launching album: $e'); + AppLogger.error('Error launching album', e); } } diff --git a/lib/widgets/activity_skeleton.dart b/lib/widgets/activity_skeleton.dart index 1063b8f..15f74fc 100644 --- a/lib/widgets/activity_skeleton.dart +++ b/lib/widgets/activity_skeleton.dart @@ -36,6 +36,9 @@ class _ActivitySkeletonState extends State return AnimatedBuilder( animation: _animation, builder: (context, child) { + final baseColor = Colors.grey[300]; + final alpha = (_animation.value * 255).toInt(); + return Card( margin: const EdgeInsets.only(bottom: 16), child: Padding( @@ -52,7 +55,7 @@ class _ActivitySkeletonState extends State height: 40, decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), @@ -68,7 +71,7 @@ class _ActivitySkeletonState extends State height: 16, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), const SizedBox(height: 4), @@ -77,7 +80,7 @@ class _ActivitySkeletonState extends State height: 12, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), ], @@ -97,7 +100,7 @@ class _ActivitySkeletonState extends State height: 60, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), @@ -113,7 +116,7 @@ class _ActivitySkeletonState extends State height: 16, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), const SizedBox(height: 4), @@ -122,7 +125,7 @@ class _ActivitySkeletonState extends State height: 14, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), const SizedBox(height: 2), @@ -131,7 +134,7 @@ class _ActivitySkeletonState extends State height: 12, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), ], @@ -144,7 +147,7 @@ class _ActivitySkeletonState extends State height: 40, decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.grey[300]?.withOpacity(_animation.value), + color: baseColor?.withAlpha(alpha), ), ), ], diff --git a/lib/widgets/currently_playing_card.dart b/lib/widgets/currently_playing_card.dart index b5f619e..6ebef2d 100644 --- a/lib/widgets/currently_playing_card.dart +++ b/lib/widgets/currently_playing_card.dart @@ -10,7 +10,11 @@ class CurrentlyPlayingCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( + return GestureDetector( + onTap: () async { + await SpotifyLauncher.launchSpotifyUri('spotify:'); + }, + child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -111,31 +115,13 @@ class CurrentlyPlayingCard extends StatelessWidget { ], ), ), - - // Play button - GestureDetector( - onTap: () async { - await SpotifyLauncher.launchSpotifyUriAndPlay(track.uri); - }, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).primaryColor.withOpacity(0.1), - ), - child: Icon( - Icons.play_arrow, - color: Theme.of(context).primaryColor, - size: 24, - ), - ), - ), + // Play button removed ], ), ], ), ), + ), ); } } \ No newline at end of file diff --git a/lib/widgets/refresh_indicator_bar.dart b/lib/widgets/refresh_indicator_bar.dart index 101f152..e4ce12d 100644 --- a/lib/widgets/refresh_indicator_bar.dart +++ b/lib/widgets/refresh_indicator_bar.dart @@ -16,14 +16,16 @@ class RefreshIndicatorBar extends StatelessWidget { return const SizedBox.shrink(); } + final primaryColor = Theme.of(context).primaryColor; + return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withOpacity(0.1), + color: primaryColor.withAlpha((0.1 * 255).round()), border: Border( bottom: BorderSide( - color: Theme.of(context).primaryColor.withOpacity(0.2), + color: primaryColor.withAlpha((0.2 * 255).round()), width: 1, ), ), @@ -36,16 +38,14 @@ class RefreshIndicatorBar extends StatelessWidget { height: 16, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).primaryColor, - ), + valueColor: AlwaysStoppedAnimation(primaryColor), ), ), const SizedBox(width: 12), Text( message ?? 'Refreshing...', style: TextStyle( - color: Theme.of(context).primaryColor, + color: primaryColor, fontSize: 14, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/spotify_webview_login.dart b/lib/widgets/spotify_webview_login.dart index 121146a..ecfa769 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -1,14 +1,10 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:http/http.dart' as http; -import '../services/spotify_service.dart'; -import '../services/spotify_buddy_service.dart'; +import '../services/app_logger.dart'; class SpotifyWebViewLogin extends StatefulWidget { - final Function(String, Map) onAuthComplete; // Bearer access token and headers + final Future Function(String, Map) onAuthComplete; // Bearer access token and headers final VoidCallback? onCancel; const SpotifyWebViewLogin({ @@ -25,8 +21,6 @@ class _SpotifyWebViewLoginState extends State { bool _isLoading = true; String? _error; Map _extractedHeaders = {}; - final bool _spDcDetected = false; - String _currentUrl = ''; bool _showOverlay = true; @override @@ -49,38 +43,38 @@ class _SpotifyWebViewLoginState extends State { ), ), ], - ), - body: Column( - children: [ - // Info banner - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - color: Theme.of(context).primaryColor.withOpacity(0.1), - child: Row( - children: [ - Icon( - Icons.security, - color: Theme.of(context).primaryColor, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: const Text( - 'Login with your Spotify account to access friend activities', - style: TextStyle(fontSize: 12), + ), body: SafeArea( + child: Column( + children: [ + // Info banner + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Row( + children: [ + Icon( + Icons.security, + color: Theme.of(context).primaryColor, + size: 20, ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: const Text( + 'Login with your Spotify account to access friend activities', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), ), - ), // Error display if (_error != null) Container( width: double.infinity, padding: const EdgeInsets.all(12), - color: Colors.red.withOpacity(0.1), + color: Colors.red.withValues(alpha: 26), // 0.1 * 255 โ‰ˆ 26 child: Row( children: [ const Icon( @@ -148,8 +142,7 @@ class _SpotifyWebViewLoginState extends State { : Stack( children: [ // WebView (always present, loading in background when overlay is shown) - InAppWebView( - initialSettings: InAppWebViewSettings( + InAppWebView( initialSettings: InAppWebViewSettings( userAgent: "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", javaScriptEnabled: true, domStorageEnabled: true, @@ -204,18 +197,17 @@ class _SpotifyWebViewLoginState extends State { if (url == null) return; String urlString = url.toString(); - print('๐ŸŒ Page loaded: $urlString'); - // Update current URL and r logic + AppLogger.auth('Page loaded: $urlString'); + setState(() { - _currentUrl = urlString; - // Show overlay only for spotify.com domain pages that are NOT /login or challenge + // Show overlay for spotify.com domain pages that are NOT /login or challenge pages // Don't show for non-spotify sites (facebook, etc) or login/challenge pages Uri uri = Uri.parse(urlString); bool isSpotifyDomain = uri.host.endsWith('spotify.com'); bool isLoginOrChallenge = urlString.contains('/login') || urlString.contains('challenge.spotify.com'); bool newShowOverlay = isSpotifyDomain && !isLoginOrChallenge; - print('๐ŸŽญ Overlay logic: host=${uri.host}, isSpotify=$isSpotifyDomain, isLogin=$isLoginOrChallenge, showOverlay=$newShowOverlay'); + AppLogger.debug('Overlay logic: host=${uri.host}, isSpotify=$isSpotifyDomain, isLogin=$isLoginOrChallenge, showOverlay=$newShowOverlay'); _showOverlay = newShowOverlay; }); @@ -223,12 +215,47 @@ class _SpotifyWebViewLoginState extends State { if (urlString.contains('open.spotify.com')) { await _setupNetworkInterception(controller, url); } - }, - onReceivedError: (controller, request, error) { + }, onReceivedError: (controller, request, error) { setState(() { _error = 'Failed to load page: ${error.description}'; _isLoading = false; }); + }, onConsoleMessage: (controller, consoleMessage) { + // Filter out CSP and Google Analytics related console messages to reduce noise + final message = consoleMessage.message.toLowerCase(); + if (message.contains('content security policy') || + message.contains('googletagmanager') || + message.contains('refused to execute inline script') || + message.contains('google-analytics') || + message.contains('gtm.js') || + message.contains('violates the following content security policy') || + message.contains('unsafe-inline') || + message.contains('unsafe-eval') || + message.contains('sha256-') || + message.contains('nonce-') || + message.contains('pixel.js') || + message.contains('analytics.twitter.com') || + message.contains('connect.facebook.net') || + message.contains('www.googleadservices.com') || + message.contains('analytics.tiktok.com') || + message.contains('redditstatic.com') || + message.contains('contentsquare.net') || + message.contains('microsoft.com') || + message.contains('scorecardresearch.com') || + message.contains('cookielaw.org') || + message.contains('onetrust.com') || + message.contains('hotjar.com') || + message.contains('ravenjs.com') || + message.contains('gstatic.com') || + message.contains('recaptcha') || + message.contains('spotifycdn.com') || + message.contains('fastly-insights.com')) { + // Silently ignore CSP violations and analytics/tracking errors + return; + } + + // Only log other console messages for debugging + AppLogger.debug('WebView Console [${consoleMessage.messageLevel}]: ${consoleMessage.message}'); }, ), @@ -259,7 +286,7 @@ class _SpotifyWebViewLoginState extends State { Text( 'Please wait while we complete your authentication', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.7), + color: Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 179), // 0.7 * 255 โ‰ˆ 179 ), textAlign: TextAlign.center, ), @@ -268,7 +295,7 @@ class _SpotifyWebViewLoginState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Colors.green.withOpacity(0.15), + color: Colors.green.withValues(alpha: 38), // 0.15 * 255 โ‰ˆ 38 borderRadius: BorderRadius.circular(20), ), child: Row( @@ -294,34 +321,34 @@ class _SpotifyWebViewLoginState extends State { ], ), ), - ), - ], + ), ], ), ), ], + ), ), ); } Future _setupNetworkInterception(InAppWebViewController controller, WebUri url) async { try { - print('๐ŸŒ Setting up network interception for Bearer token capture...'); + AppLogger.auth('Setting up network interception for Bearer token capture...'); // Get all cookies for building complete header context final cookies = await CookieManager.instance().getCookies(url: url); final cookieString = cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); - print('๐Ÿช Found ${cookies.length} cookies'); - print('๐Ÿช Initial cookie string: ${cookieString.isNotEmpty ? '${cookieString.substring(0, 100)}...' : 'EMPTY'}'); - print('๐Ÿช Cookie names: ${cookies.map((c) => c.name).join(', ')}'); + AppLogger.debug('Found ${cookies.length} cookies'); + AppLogger.debug('Initial cookie string: ${cookieString.isNotEmpty ? '${cookieString.substring(0, 100)}...' : 'EMPTY'}'); + AppLogger.debug('Cookie names: ${cookies.map((c) => c.name).join(', ')}'); // Check if sp_dc cookie is present bool hasSpDc = cookies.any((cookie) => cookie.name == 'sp_dc'); - print('๐Ÿช sp_dc cookie present in initial request: $hasSpDc'); + AppLogger.debug('sp_dc cookie present in initial request: $hasSpDc'); // Log sp_dc detection if (hasSpDc) { - print('๐Ÿ”„ sp_dc detected'); + AppLogger.auth('sp_dc detected'); } // Build headers that will be saved and reused @@ -342,13 +369,13 @@ class _SpotifyWebViewLoginState extends State { 'Upgrade-Insecure-Requests': '1', }; - print('๐Ÿ”ง Initial _extractedHeaders Cookie: ${_extractedHeaders['Cookie']?.isNotEmpty == true ? '${_extractedHeaders['Cookie']!.substring(0, 100)}...' : 'EMPTY'}'); + AppLogger.debug('Initial _extractedHeaders Cookie: ${_extractedHeaders['Cookie']?.isNotEmpty == true ? '${_extractedHeaders['Cookie']!.substring(0, 100)}...' : 'EMPTY'}'); // Set up network request interception await _interceptTokenRequests(controller); } catch (e) { - print('โŒ Failed to setup network interception: $e'); + AppLogger.error('Failed to setup network interception', e); setState(() { _error = 'Failed to setup authentication monitoring: $e'; }); @@ -357,7 +384,7 @@ class _SpotifyWebViewLoginState extends State { Future _interceptTokenRequests(InAppWebViewController controller) async { try { - print('๐Ÿ•ธ๏ธ Setting up network request interception...'); + AppLogger.auth('Setting up network request interception...'); final interceptScript = ''' (function() { @@ -498,7 +525,7 @@ class _SpotifyWebViewLoginState extends State { if (window.capturedBearerToken) { clearInterval(cookieCheckInterval); return; - } + } // Look for tokens in cookies (sometimes Spotify stores them there) const cookies = document.cookie.split(';'); @@ -592,18 +619,18 @@ class _SpotifyWebViewLoginState extends State { '''; await controller.evaluateJavascript(source: interceptScript); - print('โœ… Network interception script injected'); + AppLogger.auth('Network interception script injected'); // Set up periodic checking for captured tokens _startTokenPolling(controller); } catch (e) { - print('โŒ Error setting up network interception: $e'); + AppLogger.error('Error setting up network interception', e); } } void _startTokenPolling(InAppWebViewController controller) { - print('โฐ Starting token polling...'); + AppLogger.auth('Starting token polling...'); Timer.periodic(const Duration(seconds: 1), (timer) async { if (!mounted) { @@ -619,16 +646,16 @@ class _SpotifyWebViewLoginState extends State { bool isSpotifyDomain = uri.host.endsWith('spotify.com'); if (!isSpotifyDomain) { - print('๐Ÿšซ Not on Spotify domain (${uri.host}), skipping token polling...'); + AppLogger.debug('Not on Spotify domain (${uri.host}), skipping token polling...'); return; // Skip this polling cycle } } } catch (e) { - print('โŒ Error checking current URL for polling: $e'); + AppLogger.error('Error checking current URL for polling', e); } try { - print('๐Ÿ” Polling for captured token...'); + AppLogger.debug('Polling for captured token...'); final result = await controller.evaluateJavascript(source: ''' (function() { console.log('๐Ÿ” Polling check - capturedBearerToken:', window.capturedBearerToken ? 'EXISTS' : 'null'); @@ -672,16 +699,16 @@ class _SpotifyWebViewLoginState extends State { // Also check for sp_dc cookie updates even if no token yet await _checkForSpDcCookie(controller); - print('๐Ÿ” Polling result: ${result != null ? 'DATA FOUND' : 'null'}'); + AppLogger.debug('Polling result: ${result != null ? 'DATA FOUND' : 'null'}'); if (result != null) { final Map tokenInfo = Map.from(result); final bearerToken = tokenInfo['token'] as String?; final capturedCookie = tokenInfo['cookie'] as String?; - print('๐Ÿ” Parsed result:'); - print(' - bearerToken: ${bearerToken != null ? 'EXISTS (${bearerToken.length} chars)' : 'null'}'); - print(' - capturedCookie: ${capturedCookie != null ? 'EXISTS (${capturedCookie.length} chars)' : 'null'}'); + AppLogger.debug('Parsed result:'); + AppLogger.debug(' - bearerToken: ${bearerToken != null ? 'EXISTS (${bearerToken.length} chars)' : 'null'}'); + AppLogger.debug(' - capturedCookie: ${capturedCookie != null ? 'EXISTS (${capturedCookie.length} chars)' : 'null'}'); // Handle cookie updates (even without token) if (capturedCookie != null && capturedCookie.isNotEmpty) { @@ -689,12 +716,12 @@ class _SpotifyWebViewLoginState extends State { bool hasSpDcNow = capturedCookie.contains('sp_dc='); if (!hadSpDcBefore && hasSpDcNow) { - print('๐Ÿช Found new sp_dc cookie! Updating headers...'); + AppLogger.auth('Found new sp_dc cookie! Updating headers...'); _extractedHeaders['Cookie'] = capturedCookie; - print('โœ… Updated headers with sp_dc cookie'); - print('๐Ÿช New cookie header: ${capturedCookie.substring(0, 100)}...'); + AppLogger.auth('Updated headers with sp_dc cookie'); + AppLogger.debug('New cookie header: ${capturedCookie.substring(0, 100)}...'); - print('๐Ÿ”„ sp_dc detected in cookie update'); + AppLogger.auth('sp_dc detected in cookie update'); } else if (hasSpDcNow) { // Update with latest cookie info _extractedHeaders['Cookie'] = capturedCookie; @@ -702,55 +729,138 @@ class _SpotifyWebViewLoginState extends State { } if (bearerToken != null && bearerToken.isNotEmpty) { - print('๐ŸŽฏ Token polling found complete result!'); - timer.cancel(); + AppLogger.auth('Token polling found complete result!'); - print('๐ŸŽ‰ Successfully captured Bearer token: ${bearerToken.substring(0, 20)}...'); - print('๐Ÿ“‹ Token length: ${bearerToken.length} characters'); - print('๐Ÿช Final captured cookie: ${capturedCookie?.substring(0, 50) ?? 'none'}...'); - print('๐Ÿ“Š Token data: ${tokenInfo['data']}'); + AppLogger.auth('Successfully captured Bearer token: ${bearerToken.substring(0, 20)}...'); + AppLogger.debug('Token length: ${bearerToken.length} characters'); + AppLogger.debug('Final captured cookie: ${capturedCookie?.substring(0, 50) ?? 'none'}...'); + AppLogger.debug('Token data: ${tokenInfo['data']}'); - print('๐Ÿ”„ Bearer token found'); + AppLogger.auth('Bearer token found'); - // Complete authentication with the Bearer token and headers + // Navigate to user profile page to capture client-token + AppLogger.auth('Navigating to user profile page to capture client-token...'); + await controller.loadUrl(urlRequest: URLRequest( + url: WebUri('https://open.spotify.com/user/21fvdxlt6ejvha6jnrgdwamja'), + headers: { + 'Authorization': 'Bearer $bearerToken', + 'Cookie': _extractedHeaders['Cookie'] ?? '', + }, + )); + + // Add additional client-token interception + await controller.evaluateJavascript(source: ''' + (function() { + console.log('๐Ÿ” Setting up client-token interception...'); + + // Function to check headers for client-token + function checkForClientToken(headers) { + if (!headers) return null; + + // Handle different header formats + let clientToken = null; + if (typeof headers.get === 'function') { + clientToken = headers.get('client-token'); + } else if (typeof headers === 'object') { + clientToken = headers['client-token'] || headers['Client-Token']; + } + + if (clientToken && !window.capturedClientToken) { + console.log('โœ… Found client-token:', clientToken.substring(0, 20) + '...'); + window.capturedClientToken = clientToken; + window.dispatchEvent(new CustomEvent('spotifyClientTokenCaptured', { + detail: { clientToken: clientToken } + })); + } + return clientToken; + } + + // Intercept fetch requests for client-token + const originalFetch = window.fetch; + window.fetch = function(...args) { + const options = args[1] || {}; + checkForClientToken(options.headers); + return originalFetch.apply(this, args); + }; + + // Intercept XHR for client-token + const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + XMLHttpRequest.prototype.setRequestHeader = function(name, value) { + if (name.toLowerCase() === 'client-token') { + checkForClientToken({ 'client-token': value }); + } + return originalXHRSetRequestHeader.call(this, name, value); + }; + + console.log('โœ… Client-token interception setup complete'); + })(); + '''); + + // Start polling for client-token + bool clientTokenFound = false; + int attempts = 0; + while (!clientTokenFound && attempts < 30) { + final clientTokenResult = await controller.evaluateJavascript(source: ''' + window.capturedClientToken || null; + '''); + + if (clientTokenResult != null) { + AppLogger.auth('Client token captured: ${clientTokenResult.toString().substring(0, 20)}...'); + _extractedHeaders['client-token'] = clientTokenResult.toString(); + clientTokenFound = true; + } else { + attempts++; + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + // Complete authentication with both tokens if (mounted) { - print('๐Ÿ”„ Calling onAuthComplete with Bearer token and updated headers...'); - print('๐Ÿ“‹ Final headers: ${_extractedHeaders.keys.join(', ')}'); - print('๐Ÿ“‹ Cookie header length: ${_extractedHeaders['Cookie']?.length ?? 0}'); + AppLogger.auth('Calling onAuthComplete with Bearer token and updated headers (including client-token)...'); + AppLogger.debug('Final headers: ${_extractedHeaders.keys.join(', ')}'); - // Add debug logging for the callback try { - widget.onAuthComplete(bearerToken, _extractedHeaders); - print('โœ… onAuthComplete callback executed successfully'); + await widget.onAuthComplete(bearerToken, _extractedHeaders); + AppLogger.auth('onAuthComplete callback executed successfully'); + + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted && Navigator.canPop(context)) { + AppLogger.auth('Closing WebView after successful authentication'); + Navigator.of(context).pop(); + } } catch (e) { - print('โŒ Error in onAuthComplete callback: $e'); - } - - // Add a small delay to ensure the callback is processed - await Future.delayed(const Duration(milliseconds: 100)); - - // Close the WebView after authentication - if (mounted && Navigator.canPop(context)) { - Navigator.of(context).pop(true); + AppLogger.error('Error in onAuthComplete callback', e); + if (mounted) { + String errorMessage = e.toString(); + if (errorMessage.contains('Authentication verification failed after completion')) { + errorMessage = 'Authentication took too long to complete. Please try again or check your internet connection.'; + } + setState(() { + _error = 'Authentication failed: $errorMessage'; + }); + } } } + + timer.cancel(); } else { - print('๐Ÿช Cookie update received (no token yet)'); + AppLogger.debug('Cookie update received (no token yet)'); } } else { // Only log every 5 seconds to reduce spam if (DateTime.now().millisecondsSinceEpoch % 5000 < 1000) { - print('๐Ÿ” No token data found yet...'); + AppLogger.debug('No token data found yet...'); } } } catch (e) { - print('โŒ Error checking for captured token: $e'); + AppLogger.error('Error checking for captured token', e); } }); - // Set a timeout to stop polling after 30 seconds - Timer(const Duration(seconds: 30), () { - print('โฐ Token polling timeout - stopping...'); + // Set a timeout to stop polling after 60 seconds (extended for long idle scenarios) + Timer(const Duration(seconds: 60), () { + AppLogger.auth('Token polling timeout - stopping...'); }); } @@ -772,20 +882,20 @@ class _SpotifyWebViewLoginState extends State { ); if (spDcCookie.name.isNotEmpty && spDcCookie.value.isNotEmpty) { - print('๐Ÿช Found sp_dc cookie: ${spDcCookie.value.substring(0, 20)}...'); + AppLogger.auth('Found sp_dc cookie: ${spDcCookie.value.substring(0, 20)}...'); - print('๐Ÿ”„ sp_dc detected in cookie check'); + AppLogger.auth('sp_dc detected in cookie check'); // Update the cookie string in headers with the complete set including sp_dc final allCookies = cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); _extractedHeaders['Cookie'] = allCookies; - print('โœ… Updated headers with sp_dc cookie'); - print('๐Ÿช New cookie header: ${allCookies.substring(0, 100)}...'); + AppLogger.auth('Updated headers with sp_dc cookie'); + AppLogger.debug('New cookie header: ${allCookies.substring(0, 100)}...'); } } } catch (e) { - print('โŒ Error checking for sp_dc cookie: $e'); + AppLogger.error('Error checking for sp_dc cookie', e); } } } \ No newline at end of file diff --git a/lib/widgets/update_dialog_handler.dart b/lib/widgets/update_dialog_handler.dart new file mode 100644 index 0000000..69f1999 --- /dev/null +++ b/lib/widgets/update_dialog_handler.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import '../utils/update_launcher.dart'; + +class UpdateDialogHandler extends StatefulWidget { + final Widget child; + final Function(BuildContext) onUpdateAvailable; + + const UpdateDialogHandler({ + super.key, + required this.child, + required this.onUpdateAvailable, + }); + + @override + State createState() => _UpdateDialogHandlerState(); +} + +class _UpdateDialogHandlerState extends State { + Future showInstallPermissionDialog() async { + if (!mounted) return false; + + return 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.withAlpha(26), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withAlpha(77)), + ), + 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; + } + + Future showPermissionInstructionsDialog() async { + if (!mounted) return; + + 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.withAlpha(26), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withAlpha(77)), + ), + 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'), + ), + ], + ), + ); + } + + Future showInstallationLoadingDialog() async { + if (!mounted) return; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center(child: CircularProgressIndicator()), + SizedBox(height: 16), + Text('Starting installation...'), + ], + ), + ), + ); + } + + Future showInstallationFailedDialog(bool hasPermission) async { + if (!mounted) return; + + 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'), + ), + ], + ), + ); + } + + void showInstallationStartedSnackBar() { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Installation started. Please follow the system prompts.'), + duration: Duration(seconds: 5), + ), + ); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} \ No newline at end of file diff --git a/list-commands.js b/list-commands.js new file mode 100644 index 0000000..a821ecc --- /dev/null +++ b/list-commands.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); + +console.log(` +๐Ÿš€ Playtivity Build Scripts + +Available Commands: +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +๐Ÿ“ฑ Development Builds (Testing) + npm run build # Quick build (increment build number) + npm run build:patch # Patch version increment + npm run build:minor # Minor version increment + npm run build:major # Major version increment + +๐Ÿ—๏ธ Release Builds (Production) + npm run release # Standard release (patch increment) + npm run release:patch # Patch version release + npm run release:minor # Minor version release + npm run release:major # Major version release + npm run release:apk-only # APK only, skip AAB bundle + +๐ŸŒ™ Nightly Builds (Development) + npm run nightly # Create nightly build + npm run nightly:keep-10 # Keep 10 old nightly builds + +โฌ†๏ธ Nightly Promotion (Nightly โ†’ Release) + npm run nightly:promote # Promote latest nightly (patch) + npm run nightly:promote-patch # Promote with patch increment + npm run nightly:promote-minor # Promote with minor increment + npm run nightly:promote-major # Promote with major increment + +๐Ÿ™ GitHub Releases + npm run nightly:github # Upload latest nightly to GitHub + npm run nightly:github-stable # Upload as stable release + +โ“ Help Commands + npm run help # Development build help + npm run help:release # Release build help + npm run help:nightly # Nightly build help + npm run help:nightly-promote # Nightly promotion help + npm run help:nightly-github # GitHub release help + +Direct Script Usage: + node build-apk.js [increment-type] + node release-apk.js [increment-type] [--no-bundle] + node nightly-apk.js [--keep-builds N] + node nightly-release.js [increment-type] [--build-id ID] + node nightly-github-release.js [--build-id ID] [--stable] + +Build Outputs: + builds/ - Development builds + releases/ - Production releases + nightly/ - Nightly builds + +Requirements: + โœ… Node.js 14.0.0+ + โœ… Flutter SDK (or FVM) + โœ… Android SDK + โœ… GitHub CLI (for GitHub releases) + +For detailed documentation, see: BUILD_README.md +`); + +// Show current versions if in project directory +try { + const fs = require('fs'); + const path = require('path'); + + const pubspecPath = path.join(__dirname, 'pubspec.yaml'); + if (fs.existsSync(pubspecPath)) { + const pubspec = fs.readFileSync(pubspecPath, 'utf8'); + const versionMatch = pubspec.match(/version:\s*(.+)/); + if (versionMatch) { + console.log(`Current version: ${versionMatch[1].trim()}`); + } + } +} catch (error) { + // Ignore errors when not in project directory +} + +console.log('\n๐Ÿ’ก Quick Start: npm run build (for development) or npm run release (for production)\n'); diff --git a/nightly-apk.js b/nightly-apk.js new file mode 100644 index 0000000..da1f8e4 --- /dev/null +++ b/nightly-apk.js @@ -0,0 +1,672 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +class NightlyBuilder { + constructor() { + this.projectRoot = __dirname; + this.pubspecPath = path.join(this.projectRoot, 'pubspec.yaml'); + this.outputDir = path.join(this.projectRoot, 'nightly'); + 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': '๐Ÿ”จ', + 'nightly': '๐ŸŒƒ' + }[type] || 'โ„น๏ธ'; + + console.log(`[${timestamp}] ${prefix} ${message}`); + } + + ensureOutputDirectory() { + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + this.log(`Created nightly 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) { + 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 + }; + } + + extractBaseVersion(versionName) { + // Extract the base version by removing any existing nightly suffixes + // Handle versions like: "1.0.0-nightly-20250619-032135-nightly-20250620-050418" + // Should return: "1.0.0" + + if (versionName.includes('-nightly-')) { + const parts = versionName.split('-nightly-'); + return parts[0]; // Return everything before the first nightly suffix + } + + return versionName; + } + + createNightlyVersion() { + this.log('Creating nightly version from current pubspec.yaml', 'nightly'); + + const content = this.readPubspec(); + const lines = content.split('\n'); + + let versionLineIndex = -1; + let currentVersion = null; + + 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}`); + + // Extract base version to prevent nightly stacking + const baseVersionName = this.extractBaseVersion(currentVersion.versionName); + this.log(`Base version (cleaned): ${baseVersionName}`); + + // Create nightly version with date and time (local timezone) + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + + const dateStr = `${year}${month}${day}`; // YYYYMMDD in local timezone + const timeStr = `${hour}${minute}${second}`; // HHMMSS in local timezone + + // Format: base-version-nightly-YYYYMMDD-HHMMSS+build (using cleaned base version) + const nightlyVersionName = `${baseVersionName}-nightly-${dateStr}-${timeStr}`; + const nightlyBuildNumber = Math.floor(Date.now() / 1000); // Unix timestamp for uniqueness + + const nightlyFullVersion = `${nightlyVersionName}+${nightlyBuildNumber}`; + + // Backup original version + const backupContent = content; + + // Update version line for build + lines[versionLineIndex] = `version: ${nightlyFullVersion}`; + this.writePubspec(lines.join('\n')); + + this.log(`Nightly version created: ${nightlyFullVersion}`, 'success'); + + return { + original: currentVersion, + nightly: { + versionName: nightlyVersionName, + buildNumber: nightlyBuildNumber, + fullVersion: nightlyFullVersion + }, + backup: backupContent, + dateStr, + timeStr, + baseVersion: baseVersionName + }; + } + + restoreOriginalVersion(backup) { + this.log('Restoring original version', 'info'); + this.writePubspec(backup); + } getBuildEnvironmentInfo() { + const environment = {}; + + try { + // Get Flutter version + let flutterCommand = 'flutter'; + try { + execSync('fvm --version', { stdio: 'pipe' }); + flutterCommand = 'fvm flutter'; + } catch (error) { + // FVM not available, use system Flutter + } + + const flutterVersion = execSync(`${flutterCommand} --version --machine`, { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + const flutterInfo = JSON.parse(flutterVersion); + environment.flutterVersion = flutterInfo.frameworkVersion || 'unknown'; + environment.dartVersion = flutterInfo.dartSdkVersion || 'unknown'; + } catch (error) { + environment.flutterVersion = 'unknown'; + environment.dartVersion = 'unknown'; + } + + try { + // Get Gradle version from gradle wrapper + const gradlePath = process.platform === 'win32' ? '.\\gradlew.bat' : './gradlew'; + const gradleVersion = execSync(`${gradlePath} --version`, { + stdio: 'pipe', + encoding: 'utf8', + cwd: path.join(this.projectRoot, 'android') + }); + + const gradleMatch = gradleVersion.match(/Gradle (\d+\.\d+(?:\.\d+)?)/); + environment.gradleVersion = gradleMatch ? gradleMatch[1] : 'unknown'; + } catch (error) { + environment.gradleVersion = 'unknown'; + } try { + // Get Java version (java -version outputs to stderr, redirect to stdout) + const javaVersion = execSync('java -version 2>&1', { + stdio: 'pipe', + encoding: 'utf8' + }); + + // Match various Java version formats + let javaMatch = javaVersion.match(/openjdk version "([^"]+)"/i); + if (!javaMatch) { + javaMatch = javaVersion.match(/java version "([^"]+)"/i); + } + + environment.javaVersion = javaMatch ? javaMatch[1] : 'unknown'; + } catch (error) { + environment.javaVersion = 'unknown'; + } + + return environment; + } + + getGitInfo() { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + const commit = execSync('git rev-parse --short HEAD', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + const commitCount = execSync('git rev-list --count HEAD', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + // Get commit message + const commitMessage = execSync('git log -1 --pretty=%B', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + // Get commit author + const author = execSync('git log -1 --pretty=%an', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + // Get commit date + const date = execSync('git log -1 --pretty=%ad --date=iso', { + stdio: 'pipe', + encoding: 'utf8' + }).trim(); + + return { + branch, + commit, + commitCount, + commitMessage, + author, + date + }; + } catch (error) { + this.log('Failed to get git info, using defaults', 'warning'); + return { + branch: 'unknown', + commit: 'unknown', + commitCount: '0', + commitMessage: 'Git info unavailable', + author: 'unknown', + date: 'unknown' + }; + } + } + + 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; + } + } + + buildNightlyAPK() { + this.log('Starting nightly 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 { + // Check if pubspec.lock has changed + const pubspecLockHash = execSync('git hash-object pubspec.lock', { stdio: 'pipe' }).toString().trim(); + const pubspecLockCachePath = path.join(this.projectRoot, '.pub-cache-hash'); + let needsPubGet = true; + + if (fs.existsSync(pubspecLockCachePath)) { + const cachedHash = fs.readFileSync(pubspecLockCachePath, 'utf8').trim(); + needsPubGet = cachedHash !== pubspecLockHash; + } + + // Only run pub get if dependencies have changed + if (needsPubGet) { + this.runCommand(`${flutterCommand} pub get`, 'Getting dependencies'); + fs.writeFileSync(pubspecLockCachePath, pubspecLockHash); + } else { + this.log('Dependencies are up to date, skipping pub get', 'info'); + } + + // Check if we need to clean + const buildDir = path.join(this.projectRoot, 'build'); + const shouldClean = !fs.existsSync(buildDir) || + process.env.FORCE_CLEAN === 'true' || + process.argv.includes('--clean'); + + if (shouldClean) { + this.runCommand(`${flutterCommand} clean`, 'Cleaning previous builds'); + } else { + this.log('Skipping clean step for faster build', 'info'); + } + + // Build APK with optimized flags + const buildCommand = [ + `${flutterCommand} build apk`, + '--release', + '--target-platform android-arm64', // Focus on 64-bit only for nightly + '--dart-define=FLUTTER_BUILD_MODE=release', + '--no-tree-shake-icons', // Skip icon tree shaking for faster build + '--no-pub', // Skip pub get since we handled it above + process.env.CI ? '--suppress-analytics' : '', // Suppress analytics in CI + ].filter(Boolean).join(' '); + + this.runCommand(buildCommand, 'Building APK'); + + return true; + } catch (error) { + this.log(`Build failed: ${error.message}`, 'error'); + return false; + } + } + + copyNightlyAPK(versionInfo, gitInfo) { + if (!fs.existsSync(this.apkPath)) { + throw new Error(`APK not found at: ${this.apkPath}`); + } + + const { dateStr, timeStr } = versionInfo; + const fileName = `playtivity-nightly-${dateStr}-${timeStr}-${gitInfo.commit}.apk`; + const destinationPath = path.join(this.outputDir, fileName); + + fs.copyFileSync(this.apkPath, destinationPath); + + this.log(`Nightly APK copied to: ${destinationPath}`, 'success'); + + // Create a "latest-nightly" copy for convenience + const latestPath = path.join(this.outputDir, 'playtivity-latest-nightly.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(), + sizeBytes: stats.size + }; + } catch (error) { + return { size: 'Unknown', created: 'Unknown', sizeBytes: 0 }; + } + } + + generateNightlyInfo(versionInfo, gitInfo, apkInfo) { + const infoPath = path.join(this.outputDir, `nightly-info-${versionInfo.dateStr}-${versionInfo.timeStr}.json`); + + const nightlyInfo = { + buildType: 'nightly', + buildDate: new Date().toISOString(), + version: versionInfo.nightly, + baseVersion: { + versionName: versionInfo.baseVersion, + fullVersion: versionInfo.baseVersion, + originalFullVersion: versionInfo.original.fullVersion + }, + git: gitInfo, + apk: { + fileName: apkInfo.fileName, + size: apkInfo.size, + sizeBytes: apkInfo.sizeBytes + }, + environment: this.getBuildEnvironmentInfo() + }; + + fs.writeFileSync(infoPath, JSON.stringify(nightlyInfo, null, 2)); + + // Also create latest info + const latestInfoPath = path.join(this.outputDir, 'latest-nightly-info.json'); + fs.writeFileSync(latestInfoPath, JSON.stringify(nightlyInfo, null, 2)); + + this.log(`Nightly info saved: ${infoPath}`, 'success'); + + return { infoPath, latestInfoPath, info: nightlyInfo }; + } + + generateNightlyNotes(versionInfo, gitInfo, apkInfo, nightlyInfo) { + const notesPath = path.join(this.outputDir, `nightly-notes-${versionInfo.dateStr}-${versionInfo.timeStr}.md`); + + const notes = `# ๐ŸŒ™ Playtivity Nightly Build + +**โš ๏ธ WARNING: This is a NIGHTLY DEVELOPMENT BUILD โš ๏ธ** + +This build contains the latest development code and may be unstable. Use at your own risk! + +## Build Information +- **Build Type:** Nightly Development Build +- **Version:** \`${versionInfo.nightly.fullVersion}\` +- **Base Version:** \`${versionInfo.original.fullVersion}\` +- **Build Date:** ${new Date().toLocaleString()} +- **Build ID:** ${versionInfo.dateStr}-${versionInfo.timeStr} + +## Source Information +- **Branch:** \`${gitInfo.branch}\` +- **Commit:** \`${gitInfo.commit}\` +- **Commit Count:** ${gitInfo.commitCount} +- **Last Commit:** ${gitInfo.commitMessage} + +## APK Details +- **File:** \`${apkInfo.fileName}\` +- **Size:** ${apkInfo.size} MB +- **Install Command:** \`adb install "${apkInfo.fileName}"\` + +## โš ๏ธ Important Notes + +### This is a Nightly Build +- **Unstable**: May contain bugs, crashes, or incomplete features +- **Testing Only**: Not recommended for production use +- **Development**: Built from latest development code +- **No Support**: No official support provided for nightly builds + +### Installation +1. **Backup**: Backup your data before installing +2. **Uninstall**: May need to uninstall previous versions +3. **Enable**: Enable "Install from unknown sources" in Android settings +4. **Install**: Use ADB or manual installation + +### Feedback +- Report issues on GitHub with "nightly" label +- Include build ID: \`${versionInfo.dateStr}-${versionInfo.timeStr}\` +- Mention commit: \`${gitInfo.commit}\` + +### Next Steps +- Test new features and report feedback +- Check for newer nightly builds regularly +- Wait for stable releases for production use + +--- +*This nightly build was automatically generated from the latest development code.* +*Build System: Playtivity Nightly Builder v1.0* +`; + + fs.writeFileSync(notesPath, notes); + + // Also create latest notes + const latestNotesPath = path.join(this.outputDir, 'latest-nightly-notes.md'); + fs.writeFileSync(latestNotesPath, notes); + + this.log(`Nightly notes generated: ${notesPath}`, 'success'); + + return { notesPath, latestNotesPath }; + } + + cleanOldNightlyBuilds(keepCount = 5) { + this.log(`Cleaning old nightly builds (keeping ${keepCount} most recent)`, 'info'); + + try { + const files = fs.readdirSync(this.outputDir); + + // Filter APK files with nightly pattern + const nightlyAPKs = files + .filter(file => file.startsWith('playtivity-nightly-') && file.endsWith('.apk')) + .filter(file => !file.includes('latest')) + .sort() + .reverse(); // Most recent first + + if (nightlyAPKs.length > keepCount) { + const toDelete = nightlyAPKs.slice(keepCount); + + toDelete.forEach(apkFile => { + const apkPath = path.join(this.outputDir, apkFile); + const baseName = apkFile.replace('.apk', ''); + + // Delete APK + if (fs.existsSync(apkPath)) { + fs.unlinkSync(apkPath); + } + + // Delete associated info and notes files + const infoFile = path.join(this.outputDir, `nightly-info-${baseName.replace('playtivity-nightly-', '')}.json`); + const notesFile = path.join(this.outputDir, `nightly-notes-${baseName.replace('playtivity-nightly-', '')}.md`); + + if (fs.existsSync(infoFile)) { + fs.unlinkSync(infoFile); + } + + if (fs.existsSync(notesFile)) { + fs.unlinkSync(notesFile); + } + }); + + this.log(`Cleaned ${toDelete.length} old nightly builds`, 'success'); + } else { + this.log('No old nightly builds to clean', 'info'); + } + } catch (error) { + this.log(`Failed to clean old builds: ${error.message}`, 'warning'); + } + } + + async buildNightly(keepBuilds = 5) { + let versionBackup = null; + + try { + this.log('๐ŸŒƒ Starting Playtivity Nightly Build Process', 'nightly'); + + // Setup + this.ensureOutputDirectory(); + + // Get git information + const gitInfo = this.getGitInfo(); + this.log(`Building from branch: ${gitInfo.branch} (${gitInfo.commit})`, 'info'); + + // Create nightly version + const versionInfo = this.createNightlyVersion(); + versionBackup = versionInfo.backup; + + // Build APK + const buildSuccess = this.buildNightlyAPK(); + + if (!buildSuccess) { + throw new Error('Nightly build failed'); + } + + // Copy APK to nightly directory + const apkInfo = this.copyNightlyAPK(versionInfo, gitInfo); + const fileInfo = this.getAPKInfo(apkInfo.timestampedPath); + + // Generate build information and notes + const nightlyInfoResult = this.generateNightlyInfo(versionInfo, gitInfo, { ...apkInfo, ...fileInfo }); + const notesResult = this.generateNightlyNotes(versionInfo, gitInfo, { ...apkInfo, ...fileInfo }, nightlyInfoResult.info); + + // Clean old builds + this.cleanOldNightlyBuilds(keepBuilds); + + // Restore original version + this.restoreOriginalVersion(versionBackup); + versionBackup = null; + + // Success summary + this.log('๐ŸŽ‰ Nightly build completed successfully!', 'success'); + + console.log('\n๐ŸŒ™ Nightly Build Summary:'); + console.log(` Build Type: NIGHTLY DEVELOPMENT BUILD`); + console.log(` Version: ${versionInfo.nightly.fullVersion}`); + console.log(` Base Version: ${versionInfo.original.fullVersion}`); + console.log(` Branch: ${gitInfo.branch}`); + console.log(` Commit: ${gitInfo.commit}`); + console.log(` APK Size: ${fileInfo.size} MB`); + console.log(` Location: ${apkInfo.timestampedPath}`); + console.log(` Latest: ${apkInfo.latestPath}`); + + console.log('\n๐Ÿ“‹ Generated Files:'); + console.log(` Info: ${nightlyInfoResult.infoPath}`); + console.log(` Notes: ${notesResult.notesPath}`); + + console.log('\nโš ๏ธ IMPORTANT:'); + console.log(' This is a NIGHTLY DEVELOPMENT BUILD'); + console.log(' - May contain bugs and incomplete features'); + console.log(' - For testing purposes only'); + console.log(' - Not recommended for production use'); + + console.log('\n๐Ÿ“ฑ Installation:'); + console.log(` adb install "${apkInfo.latestPath}"`); + console.log(' or transfer to your device and install manually'); + + } catch (error) { + // Restore original version if something went wrong + if (versionBackup) { + try { + this.restoreOriginalVersion(versionBackup); + this.log('Original version restored after error', 'info'); + } catch (restoreError) { + this.log(`Failed to restore original version: ${restoreError.message}`, 'error'); + } + } + + this.log(`Nightly build process failed: ${error.message}`, 'error'); + process.exit(1); + } + } +} + +// CLI interface +function showHelp() { + console.log(` +๐ŸŒ™ Playtivity Nightly Builder + +Usage: node nightly-apk.js [options] + +Options: + --keep-builds - Number of old nightly builds to keep (default: 5) + --help, -h - Show this help message + +Examples: + node nightly-apk.js # Build nightly with default settings + node nightly-apk.js --keep-builds 10 # Keep 10 old builds + +About Nightly Builds: + - Built from current development code + - Versioned with date, time, and git commit + - Marked clearly as NIGHTLY DEVELOPMENT BUILDS + - Include git information and build metadata + - Automatically clean old builds + - NOT for production use + +The script will: + โœ… Create nightly version with timestamp + โœ… Build APK with nightly branding + โœ… Include git commit information + โœ… Generate detailed build notes + โœ… Clean old nightly builds + โœ… Restore original version after build +`); +} + +// Main execution +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + process.exit(0); + } + + // Parse keep-builds option + let keepBuilds = 5; + const keepIndex = args.indexOf('--keep-builds'); + if (keepIndex !== -1 && args[keepIndex + 1]) { + keepBuilds = parseInt(args[keepIndex + 1]) || 5; + } + + const builder = new NightlyBuilder(); + builder.buildNightly(keepBuilds); +} + +module.exports = NightlyBuilder; diff --git a/nightly-github-release.js b/nightly-github-release.js new file mode 100644 index 0000000..ec704d7 --- /dev/null +++ b/nightly-github-release.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const crypto = require('crypto'); + +class NightlyGitHubReleaser { constructor() { + this.projectRoot = __dirname; + this.nightlyDir = path.join(this.projectRoot, 'nightly'); + this.githubRepo = this.detectGitHubRepo(); + } + + log(message, type = 'info') { + const timestamp = new Date().toLocaleTimeString(); + const prefix = { + 'info': '๐ŸŒ™', + 'success': 'โœ…', + 'error': 'โŒ', + 'warning': 'โš ๏ธ', + 'upload': '๐Ÿ“ค', + 'github': '๐Ÿ™', + 'nightly': '๐ŸŒƒ' + }[type] || 'โ„น๏ธ'; + + console.log(`[${timestamp}] ${prefix} ${message}`); + } + + detectGitHubRepo() { + try { + const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim(); + + // Handle both HTTPS and SSH URLs + let repoMatch = remoteUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+)(?:\.git)?$/); + + if (repoMatch) { + return `${repoMatch[1]}/${repoMatch[2]}`; + } + + throw new Error('Could not parse GitHub repository from remote URL'); + } catch (error) { + this.log(`Failed to detect GitHub repo: ${error.message}`, 'warning'); + return null; + } + } checkGitHubCLI() { + try { + execSync('gh --version', { stdio: 'ignore' }); + this.log('GitHub CLI detected', 'github'); + } catch (error) { + throw new Error(` +GitHub CLI not found. Please install it first: + +Download from: https://cli.github.com/ +Or install with PowerShell: winget install GitHub.cli + +After installation, authenticate with: gh auth login +`); + } + + // Check if authenticated + try { + execSync('gh auth status', { stdio: 'ignore' }); + this.log('GitHub CLI authenticated', 'success'); + } catch (error) { + throw new Error(` +GitHub CLI not authenticated. Please run: gh auth login + +This will open a web browser to authenticate with GitHub. +`); + } + } + + checkGitHubRepo() { + if (!this.githubRepo) { + throw new Error('GitHub repository not detected. Ensure you are in a git repository with GitHub origin.'); + } + this.log(`Repository: ${this.githubRepo}`, 'github'); + } + + listNightlyBuilds() { + if (!fs.existsSync(this.nightlyDir)) { + throw new Error('No nightly builds found. Run nightly build first.'); + } + + const files = fs.readdirSync(this.nightlyDir); + const nightlyAPKs = files + .filter(file => file.startsWith('playtivity-nightly-') && file.endsWith('.apk')) + .filter(file => !file.includes('latest')) + .sort() + .reverse(); // Most recent first + + if (nightlyAPKs.length === 0) { + throw new Error('No nightly APK files found in nightly directory.'); + } + + return nightlyAPKs.map(apk => { + const apkPath = path.join(this.nightlyDir, apk); + const stats = fs.statSync(apkPath); + + // Extract build ID from filename + const buildId = apk.match(/playtivity-nightly-(\d{8}-\d{6})-/)?.[1]; + const infoPath = buildId ? path.join(this.nightlyDir, `nightly-info-${buildId}.json`) : null; + + let buildInfo = null; + if (infoPath && fs.existsSync(infoPath)) { + try { + buildInfo = JSON.parse(fs.readFileSync(infoPath, 'utf8')); + } catch (error) { + this.log(`Failed to read build info for ${apk}: ${error.message}`, 'warning'); + } + } + + return { + fileName: apk, + path: apkPath, + size: (stats.size / (1024 * 1024)).toFixed(2), + created: stats.birthtime.toLocaleString(), + buildId, + buildInfo + }; + }); + } + + selectNightlyBuild(nightlyBuilds, buildId = null) { + if (buildId) { + const build = nightlyBuilds.find(b => b.buildId === buildId); + if (!build) { + throw new Error(`Nightly build with ID ${buildId} not found.`); + } + return build; + } + + // If no buildId specified, use the latest + return nightlyBuilds[0]; + } + + generateChecksum(filePath) { + const fileBuffer = fs.readFileSync(filePath); + const hashSum = crypto.createHash('sha256'); + hashSum.update(fileBuffer); + return hashSum.digest('hex'); + } createGitHubRelease(build, prerelease = true) { + this.log(`Creating GitHub release for nightly build: ${build.fileName}`, 'github'); + + const tagName = `nightly-${build.buildId}`; + const releaseName = `๐ŸŒ™ Nightly Build ${build.buildId}`; + + // Generate release body + const checksum = this.generateChecksum(build.path); + const releaseBody = this.generateReleaseNotes(build, checksum); + + // Create release using GitHub CLI + this.createReleaseWithGHCLI(tagName, releaseName, releaseBody, build, prerelease); + } createReleaseWithGHCLI(tagName, releaseName, releaseBody, build, prerelease) { + const prereleaseFlag = prerelease ? '--prerelease' : ''; + const notesFile = path.join(this.nightlyDir, `release-notes-${build.buildId}.md`); + + // Write release notes to temporary file + fs.writeFileSync(notesFile, releaseBody); + + try { + // Create release with GitHub CLI + const createCmd = `gh release create "${tagName}" "${build.path}" --title "${releaseName}" --notes-file "${notesFile}" ${prereleaseFlag}`; + + this.log('Creating release with GitHub CLI...', 'upload'); + execSync(createCmd, { + stdio: 'inherit', + cwd: this.projectRoot + }); + + this.log(`โœ… GitHub release created: https://github.com/${this.githubRepo}/releases/tag/${tagName}`, 'success'); + + } catch (error) { + throw new Error(`Failed to create GitHub release: ${error.message}`); + } finally { + // Clean up temporary file + if (fs.existsSync(notesFile)) { + fs.unlinkSync(notesFile); + } + } + } generateReleaseNotes(build, checksum) { + const buildInfo = build.buildInfo; + + let releaseNotes = `# ๐ŸŒ™ Playtivity Nightly Build + +**โš ๏ธ WARNING: This is a NIGHTLY DEVELOPMENT BUILD โš ๏ธ** + +This build contains the latest development code and may be unstable. Use at your own risk! + +## Build Information + +- **Build Date**: ${build.created} +- **Build ID**: ${build.buildId} +- **File Size**: ${build.size} MB +- **File Name**: ${build.fileName} + +## Version Information +`; if (buildInfo) { + // Clean version display - show nightly version properly formatted + const nightlyVersion = buildInfo.version?.fullVersion || buildInfo.version?.versionName || 'Unknown'; + const baseVersion = buildInfo.baseVersion || this.extractBaseVersionFromBuildInfo(buildInfo) || 'Unknown'; + + releaseNotes += ` +- **Version**: \`${nightlyVersion}\` +- **Base Version**: \`${baseVersion}\` + +## Git Information + +- **Branch**: \`${buildInfo.git?.branch || 'unknown'}\` +- **Commit**: \`${buildInfo.git?.commit || 'unknown'}\` +- **Commit Message**: "${buildInfo.git?.commitMessage || 'Unknown'}" +- **Author**: ${buildInfo.git?.author || 'unknown'} +- **Date**: ${buildInfo.git?.date || 'unknown'} + +## Build Environment + +- **Flutter Version**: ${buildInfo.environment?.flutterVersion || 'unknown'} +- **Dart Version**: ${buildInfo.environment?.dartVersion || 'unknown'} +- **Gradle Version**: ${buildInfo.environment?.gradleVersion || 'unknown'} +- **Java Version**: ${buildInfo.environment?.javaVersion || 'unknown'} +`; + } + + releaseNotes += ` +## Security + +- **SHA256 Checksum**: \`${checksum}\` + +## Installation + +### Android +1. Download the APK file from the assets below +2. Enable "Install from unknown sources" in your Android settings +3. Install the APK file + +### Using ADB +\`\`\`bash +adb install ${build.fileName} +\`\`\` + +## โš ๏ธ Important Notes + +- **Unstable**: This build may contain bugs, crashes, or incomplete features +- **Testing Only**: Not recommended for daily use +- **Data Loss Risk**: Always backup your data before installing nightly builds +- **No Support**: Limited support provided for nightly builds + +## What's New in This Nightly + +This nightly build includes all development changes up to commit **${buildInfo?.git?.commit?.substring(0, 7) || 'unknown'}**. + +For detailed changes, check the [commit history](https://github.com/${this.githubRepo}/commits/${buildInfo?.git?.branch || 'main'}). + +--- +*๐Ÿค– This is an automated nightly release built from the latest development code* +`; + + return releaseNotes; + } + + extractBaseVersionFromBuildInfo(buildInfo) { + // Helper to extract base version from build info if available + if (buildInfo.baseVersion) { + if (typeof buildInfo.baseVersion === 'string') { + return buildInfo.baseVersion; + } + return buildInfo.baseVersion.fullVersion || buildInfo.baseVersion.versionName; + } + + // Fallback: try to extract from nightly version + const nightlyVersion = buildInfo.version?.versionName || buildInfo.version?.fullVersion; + if (nightlyVersion && nightlyVersion.includes('-nightly-')) { + return nightlyVersion.split('-nightly-')[0]; + } + + return null; + } + + async releaseNightly(buildId = null, prerelease = true) { + try { this.log('๐ŸŒ™ Starting Nightly GitHub Release Process', 'nightly'); + + // Validation + this.checkGitHubCLI(); + this.checkGitHubRepo(); + + // List available nightly builds + this.log('Looking for nightly builds...', 'info'); + const nightlyBuilds = this.listNightlyBuilds(); + + this.log(`Found ${nightlyBuilds.length} nightly build(s)`, 'success'); + + // Select build to release + const selectedBuild = this.selectNightlyBuild(nightlyBuilds, buildId); + + this.log(`Selected build: ${selectedBuild.fileName} (${selectedBuild.size} MB)`, 'info'); + if (selectedBuild.buildInfo) { + const commit = selectedBuild.buildInfo.git?.commit || 'unknown'; + const branch = selectedBuild.buildInfo.git?.branch || 'unknown'; + const commitShort = commit.length > 7 ? commit.substring(0, 7) : commit; + this.log(`Git: ${branch}@${commitShort}`, 'info'); + } + + // Create GitHub release + this.createGitHubRelease(selectedBuild, prerelease); + + this.log('๐ŸŽ‰ Nightly GitHub release completed successfully!', 'success'); + + } catch (error) { + this.log(`โŒ Nightly GitHub release failed: ${error.message}`, 'error'); + process.exit(1); + } + } +} + +function showHelp() { + console.log(` +๐ŸŒ™ Playtivity Nightly GitHub Releaser + +Usage: node nightly-github-release.js [options] + +Options: + --build-id - Release specific nightly build (e.g., 20250614-143022) + --stable - Mark as stable release (not prerelease) + --help, -h - Show this help message + +Requirements: + - GitHub CLI (gh) installed and authenticated + - GitHub repository with write access + - Existing nightly builds in nightly/ folder + +Setup: + 1. Install GitHub CLI: https://cli.github.com/ + 2. Authenticate: gh auth login + 3. Ensure you have write access to the repository + +Examples: + node nightly-github-release.js # Release latest nightly + node nightly-github-release.js --build-id 20250614-143022 # Release specific build + node nightly-github-release.js --stable # Mark as stable release + +The script will: + โœ… Find available nightly builds + โœ… Create GitHub release with detailed notes + โœ… Upload APK as release asset + โœ… Include git commit information + โœ… Generate SHA256 checksum + โœ… Mark as prerelease by default +`); +} + +// Main execution +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + process.exit(0); + } + + // Parse options + let buildId = null; + let prerelease = true; + + const buildIdIndex = args.indexOf('--build-id'); + if (buildIdIndex !== -1 && args[buildIdIndex + 1]) { + buildId = args[buildIdIndex + 1]; + } + + if (args.includes('--stable')) { + prerelease = false; + } + + const releaser = new NightlyGitHubReleaser(); + releaser.releaseNightly(buildId, prerelease); +} + +module.exports = NightlyGitHubReleaser; diff --git a/nightly-release.js b/nightly-release.js new file mode 100644 index 0000000..eb87ae9 --- /dev/null +++ b/nightly-release.js @@ -0,0 +1,677 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const crypto = require('crypto'); + +class NightlyReleaser { + constructor() { + this.projectRoot = __dirname; + this.pubspecPath = path.join(this.projectRoot, 'pubspec.yaml'); + this.nightlyDir = path.join(this.projectRoot, 'nightly'); + this.releasesDir = path.join(this.projectRoot, 'releases'); + this.keystorePath = path.join(this.projectRoot, 'release-key.jks'); + this.keystoreBase64Path = path.join(this.projectRoot, 'keystore.base64.txt'); + } + + log(message, type = 'info') { + const timestamp = new Date().toLocaleTimeString(); + const prefix = { + 'info': '๐Ÿ“ฑ', + 'success': 'โœ…', + 'error': 'โŒ', + 'warning': 'โš ๏ธ', + 'build': '๐Ÿ”จ', + 'release': '๐Ÿš€', + 'nightly': '๐ŸŒ™', + 'promote': 'โฌ†๏ธ' + }[type] || 'โ„น๏ธ'; + + console.log(`[${timestamp}] ${prefix} ${message}`); + } + + ensureDirectories() { + if (!fs.existsSync(this.releasesDir)) { + fs.mkdirSync(this.releasesDir, { recursive: true }); + this.log(`Created releases directory: ${this.releasesDir}`); + } + } + + listNightlyBuilds() { + if (!fs.existsSync(this.nightlyDir)) { + throw new Error('No nightly builds found. Run nightly build first.'); + } + + const files = fs.readdirSync(this.nightlyDir); + const nightlyAPKs = files + .filter(file => file.startsWith('playtivity-nightly-') && file.endsWith('.apk')) + .filter(file => !file.includes('latest')) + .sort() + .reverse(); // Most recent first + + if (nightlyAPKs.length === 0) { + throw new Error('No nightly APK files found in nightly directory.'); + } + + return nightlyAPKs.map(apk => { + const apkPath = path.join(this.nightlyDir, apk); + const stats = fs.statSync(apkPath); + + // Try to find associated info file + const buildId = apk.match(/playtivity-nightly-(\d{8}-\d{6})-/)?.[1]; + const infoPath = buildId ? path.join(this.nightlyDir, `nightly-info-${buildId}.json`) : null; + + let buildInfo = null; + if (infoPath && fs.existsSync(infoPath)) { + try { + buildInfo = JSON.parse(fs.readFileSync(infoPath, 'utf8')); + } catch (error) { + this.log(`Failed to read build info for ${apk}: ${error.message}`, 'warning'); + } + } + + return { + fileName: apk, + path: apkPath, + size: (stats.size / (1024 * 1024)).toFixed(2), + created: stats.birthtime.toLocaleString(), + buildId, + buildInfo + }; + }); + } + + selectNightlyBuild(nightlyBuilds, buildId = null) { + if (buildId) { + const build = nightlyBuilds.find(b => b.buildId === buildId); + if (!build) { + throw new Error(`Nightly build with ID ${buildId} not found.`); + } + return build; + } + + // If no buildId specified, use the latest + return nightlyBuilds[0]; + } + + 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) { + 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 + }; + } + + getCurrentVersion() { + const content = this.readPubspec(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('version:')) { + return this.parseVersion(lines[i]); + } + } + + throw new Error('Version line not found in pubspec.yaml'); + } + + createReleaseVersion(incrementType = 'patch', customVersion = null) { + this.log('Creating release version from nightly', 'promote'); + + if (customVersion) { + const newFullVersion = `${customVersion}+1`; + this.updateVersionInPubspec(newFullVersion); + + return { + versionName: customVersion, + buildNumber: 1, + fullVersion: newFullVersion + }; + } + + const content = this.readPubspec(); + const lines = content.split('\n'); + + let versionLineIndex = -1; + let currentVersion = null; + + 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'); + } + + // Extract base version from nightly version if it exists + let baseVersionName = currentVersion.versionName; + if (baseVersionName.includes('-nightly-')) { + baseVersionName = baseVersionName.split('-nightly-')[0]; + } + + this.log(`Base version: ${baseVersionName}`); + + let newVersionName = baseVersionName; + + // Increment based on type + switch (incrementType) { + case 'major': + const [major, minor, patch] = newVersionName.split('.'); + newVersionName = `${parseInt(major) + 1}.0.0`; + break; + case 'minor': + const [maj, min, pat] = newVersionName.split('.'); + newVersionName = `${maj}.${parseInt(min) + 1}.0`; + break; + case 'patch': + const [ma, mi, pa] = newVersionName.split('.'); + newVersionName = `${ma}.${mi}.${parseInt(pa) + 1}`; + break; + case 'none': + // Don't increment version + break; + default: + throw new Error(`Invalid increment type: ${incrementType}`); + } + + const newFullVersion = `${newVersionName}+1`; + + lines[versionLineIndex] = `version: ${newFullVersion}`; + this.writePubspec(lines.join('\n')); + + this.log(`Release version created: ${newFullVersion}`, 'success'); + + return { + versionName: newVersionName, + buildNumber: 1, + fullVersion: newFullVersion + }; + } + + updateVersionInPubspec(newFullVersion) { + const content = this.readPubspec(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('version:')) { + lines[i] = `version: ${newFullVersion}`; + break; + } + } + + this.writePubspec(lines.join('\n')); + } + + setupKeystore() { + this.log('Setting up keystore for release signing', 'release'); + + if (fs.existsSync(this.keystorePath)) { + this.log('Keystore already exists, skipping setup'); + return; + } + + if (fs.existsSync(this.keystoreBase64Path)) { + try { + const base64Content = fs.readFileSync(this.keystoreBase64Path, 'utf8').trim(); + const keystoreData = Buffer.from(base64Content, 'base64'); + fs.writeFileSync(this.keystorePath, keystoreData); + this.log('Keystore decoded from base64 file', 'success'); + return; + } catch (error) { + this.log(`Failed to decode keystore from base64: ${error.message}`, 'warning'); + } + } + + throw new Error('No keystore found. Please ensure release-key.jks exists or keystore.base64.txt contains valid base64 data.'); + } + + promptForKeystoreCredentials() { + this.log('Setting up keystore credentials', 'release'); + + const keystorePassword = process.env.ANDROID_KEYSTORE_PASSWORD || 'playtivity123'; + const keyAlias = process.env.ANDROID_KEY_ALIAS || 'playtivity-key'; + const keyPassword = process.env.ANDROID_KEY_PASSWORD || 'playtivity123'; + + process.env.ANDROID_KEYSTORE_PATH = this.keystorePath; + process.env.ANDROID_KEYSTORE_PASSWORD = keystorePassword; + process.env.ANDROID_KEY_ALIAS = keyAlias; + process.env.ANDROID_KEY_PASSWORD = keyPassword; + + this.log('Keystore credentials configured', 'success'); + + return { + keystorePath: this.keystorePath, + keystorePassword, + keyAlias, + keyPassword + }; + } + + runCommand(command, description) { + this.log(`${description}...`, 'build'); + try { + const output = execSync(command, { + cwd: this.projectRoot, + stdio: 'pipe', + encoding: 'utf8', + env: { ...process.env } + }); + this.log(`${description} completed`, 'success'); + return output; + } catch (error) { + this.log(`${description} failed: ${error.message}`, 'error'); + throw error; + } + } + + buildSignedAPK() { + this.log('Building signed release APK from nightly code', 'build'); + + 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 { + this.runCommand(`${flutterCommand} clean`, 'Cleaning project'); + this.runCommand(`${flutterCommand} pub get`, 'Getting dependencies'); + + try { + this.runCommand(`${flutterCommand} test`, 'Running tests'); + } catch (error) { + this.log('Tests failed, but continuing with release build', 'warning'); + } + + this.runCommand(`${flutterCommand} build apk --release`, 'Building signed release APK'); + + return true; + } catch (error) { + this.log(`Release build failed: ${error.message}`, 'error'); + return false; + } + } + + buildSignedBundle() { + this.log('Building signed App Bundle (AAB)', 'build'); + + let flutterCommand = 'flutter'; + try { + execSync('fvm --version', { stdio: 'pipe' }); + flutterCommand = 'fvm flutter'; + } catch (error) { + // FVM not available + } + + try { + this.runCommand(`${flutterCommand} build appbundle --release`, 'Building signed release bundle'); + return true; + } catch (error) { + this.log(`Bundle build failed: ${error.message}`, 'warning'); + return false; + } + } + + generateChecksum(filePath) { + try { + const fileBuffer = fs.readFileSync(filePath); + const hashSum = crypto.createHash('sha256'); + hashSum.update(fileBuffer); + return hashSum.digest('hex'); + } catch (error) { + this.log(`Failed to generate checksum: ${error.message}`, 'warning'); + return null; + } + } + + copyReleaseFiles(version, nightlyBuild) { + this.log('Copying release files to releases directory', 'release'); + + const apkPath = path.join(this.projectRoot, 'build', 'app', 'outputs', 'flutter-apk', 'app-release.apk'); + const bundlePath = path.join(this.projectRoot, 'build', 'app', 'outputs', 'bundle', 'release', 'app-release.aab'); + + const releaseInfo = { + version: version, + nightlySource: nightlyBuild, + timestamp: new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16), + files: [] + }; + + // Copy APK + if (fs.existsSync(apkPath)) { + const apkFileName = `playtivity-v${version.versionName}-from-nightly-release.apk`; + const apkDestPath = path.join(this.releasesDir, apkFileName); + + fs.copyFileSync(apkPath, apkDestPath); + + const apkInfo = this.getFileInfo(apkDestPath); + const checksum = this.generateChecksum(apkDestPath); + + releaseInfo.files.push({ + type: 'APK', + name: apkFileName, + path: apkDestPath, + size: apkInfo.size, + checksum: checksum + }); + + this.log(`Release APK copied: ${apkFileName}`, 'success'); + } + + // Copy AAB if exists + if (fs.existsSync(bundlePath)) { + const bundleFileName = `playtivity-v${version.versionName}-from-nightly-release.aab`; + const bundleDestPath = path.join(this.releasesDir, bundleFileName); + + fs.copyFileSync(bundlePath, bundleDestPath); + + const bundleInfo = this.getFileInfo(bundleDestPath); + + releaseInfo.files.push({ + type: 'AAB', + name: bundleFileName, + path: bundleDestPath, + size: bundleInfo.size, + checksum: this.generateChecksum(bundleDestPath) + }); + + this.log(`Release Bundle copied: ${bundleFileName}`, 'success'); + } + + return releaseInfo; + } + + getFileInfo(filePath) { + try { + const stats = fs.statSync(filePath); + const sizeInMB = (stats.size / (1024 * 1024)).toFixed(2); + return { + size: sizeInMB, + created: stats.birthtime.toLocaleString(), + sizeBytes: stats.size + }; + } catch (error) { + return { size: 'Unknown', created: 'Unknown', sizeBytes: 0 }; + } + } + + generateReleaseNotes(version, releaseInfo, nightlyBuild) { + const releaseNotesPath = path.join(this.releasesDir, `release-notes-v${version.versionName}-from-nightly.md`); + + const gitInfo = nightlyBuild.buildInfo?.git || {}; + + const notes = `# Playtivity Release v${version.versionName} (From Nightly) + +## Release Information +- **Version:** ${version.versionName} +- **Build Number:** ${version.buildNumber} +- **Release Date:** ${new Date().toLocaleDateString()} +- **Release Type:** Promoted from Nightly Build +- **Source Nightly:** ${nightlyBuild.fileName} +- **Source Build ID:** ${nightlyBuild.buildId || 'Unknown'} + +## Source Nightly Build Details +- **Original Build Date:** ${nightlyBuild.created} +- **Original APK Size:** ${nightlyBuild.size} MB +- **Source Branch:** \`${gitInfo.branch || 'unknown'}\` +- **Source Commit:** \`${gitInfo.commit || 'unknown'}\` +- **Commit Message:** ${gitInfo.commitMessage || 'Unknown'} + +## Release Files + +${releaseInfo.files.map(file => ` +### ${file.type} +- **File:** \`${file.name}\` +- **Size:** ${file.size} MB +- **SHA256:** \`${file.checksum || 'N/A'}\` +`).join('\n')} + +## Promotion Notes +This release was created by promoting a nightly development build to a stable release. The nightly build was tested and deemed stable enough for release. + +### Why Promote from Nightly? +- โœ… Nightly build was thoroughly tested +- โœ… Contains important bug fixes or features +- โœ… Faster than creating a new release build from scratch +- โœ… Maintains development-to-release traceability + +## Installation Instructions + +### APK Installation +1. Enable "Install from unknown sources" in Android settings +2. Download the APK file: \`${releaseInfo.files.find(f => f.type === 'APK')?.name || 'N/A'}\` +3. Open the downloaded file and follow installation prompts + +### Using ADB +\`\`\`bash +adb install "${releaseInfo.files.find(f => f.type === 'APK')?.name || 'playtivity-release.apk'}" +\`\`\` + +## Verification +You can verify the integrity of the downloaded files using their SHA256 checksums listed above. + +## Changes in this Release + +- Promoted from stable nightly build +- Contains latest development features and fixes +- Fully tested and verified for release + +--- +*This release was promoted from nightly build: ${nightlyBuild.fileName}* +*Generated by Playtivity Nightly Release Promoter* +`; + + fs.writeFileSync(releaseNotesPath, notes, 'utf8'); + this.log(`Release notes generated: ${releaseNotesPath}`, 'success'); + + return releaseNotesPath; + } + + async releaseFromNightly(options = {}) { + const { + buildId = null, + incrementType = 'patch', + customVersion = null, + buildBundle = true, + listOnly = false + } = options; + + try { + this.log('๐Ÿš€ Starting Nightly-to-Release Promotion Process', 'promote'); + + // List available nightly builds + const nightlyBuilds = this.listNightlyBuilds(); + + if (listOnly) { + console.log('\n๐Ÿ“‹ Available Nightly Builds:'); + nightlyBuilds.forEach((build, index) => { + const gitInfo = build.buildInfo?.git || {}; + console.log(`\n${index + 1}. ${build.fileName}`); + console.log(` Build ID: ${build.buildId || 'Unknown'}`); + console.log(` Created: ${build.created}`); + console.log(` Size: ${build.size} MB`); + console.log(` Branch: ${gitInfo.branch || 'unknown'}`); + console.log(` Commit: ${gitInfo.commit || 'unknown'}`); + if (gitInfo.commitMessage) { + console.log(` Message: ${gitInfo.commitMessage.substring(0, 60)}...`); + } + }); + return; + } + + // Select nightly build + const selectedBuild = this.selectNightlyBuild(nightlyBuilds, buildId); + this.log(`Selected nightly build: ${selectedBuild.fileName}`, 'success'); + + // Setup + this.ensureDirectories(); + this.setupKeystore(); + this.promptForKeystoreCredentials(); + + // Create release version + const releaseVersion = this.createReleaseVersion(incrementType, customVersion); + + // Build signed APK and Bundle + const apkSuccess = this.buildSignedAPK(); + if (!apkSuccess) { + throw new Error('Release APK build failed'); + } + + if (buildBundle) { + this.buildSignedBundle(); + } + + // Copy and organize release files + const releaseInfo = this.copyReleaseFiles(releaseVersion, selectedBuild); + + // Generate release notes + const releaseNotesPath = this.generateReleaseNotes(releaseVersion, releaseInfo, selectedBuild); + + // Success summary + this.log('๐ŸŽ‰ Nightly-to-Release promotion completed successfully!', 'success'); + + console.log('\n๐Ÿ“‹ Release Promotion Summary:'); + console.log(` Source Nightly: ${selectedBuild.fileName}`); + console.log(` Release Version: ${releaseVersion.fullVersion}`); + console.log(` Release Directory: ${this.releasesDir}`); + console.log(` Release Notes: ${releaseNotesPath}`); + + console.log('\n๐Ÿ“ฑ Release Files:'); + releaseInfo.files.forEach(file => { + console.log(` ${file.type}: ${file.name} (${file.size} MB)`); + }); + + console.log('\n๐Ÿ” Security:'); + console.log(' โœ… APK signed with release keystore'); + console.log(' โœ… SHA256 checksums generated'); + console.log(' โœ… Promoted from tested nightly build'); + + console.log('\n๐Ÿ“ค Next Steps:'); + console.log(' 1. Test the promoted release APK'); + console.log(' 2. Upload AAB to Google Play Console (if available)'); + console.log(' 3. Create GitHub release with generated files'); + console.log(' 4. Update release notes with actual changes'); + + } catch (error) { + this.log(`Nightly-to-Release promotion failed: ${error.message}`, 'error'); + process.exit(1); + } + } +} + +// CLI interface +function showHelp() { + console.log(` +๐Ÿš€ Playtivity Nightly Release Promoter + +Usage: node nightly-release.js [options] + +Options: + --build-id - Specific nightly build ID to promote (YYYYMMDD-HHMMSS) + --increment - Version increment: patch, minor, major, none (default: patch) + --version - Custom version number (e.g., 1.2.3) + --no-bundle - Skip building Android App Bundle (AAB) + --list - List available nightly builds and exit + --help, -h - Show this help message + +Examples: + node nightly-release.js # Promote latest nightly as patch + node nightly-release.js --list # List available nightly builds + node nightly-release.js --build-id 20250614-143022 # Promote specific nightly + node nightly-release.js --increment minor # Promote as minor version + node nightly-release.js --version 1.0.0 # Promote with custom version + node nightly-release.js --no-bundle # APK only, no AAB + +About Nightly Release Promotion: + - Promotes tested nightly builds to official releases + - Creates properly signed APK and AAB files + - Maintains traceability from nightly to release + - Faster than building from scratch + - Includes source nightly build information + +The script will: + โœ… List available nightly builds for selection + โœ… Create new release version in pubspec.yaml + โœ… Build signed release APK and AAB + โœ… Generate checksums and detailed release notes + โœ… Organize files in releases/ folder with nightly source info +`); +} + +// Main execution +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + process.exit(0); + } + + const options = {}; + + // Parse arguments + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--build-id': + options.buildId = args[++i]; + break; + case '--increment': + options.incrementType = args[++i]; + break; + case '--version': + options.customVersion = args[++i]; + break; + case '--no-bundle': + options.buildBundle = false; + break; + case '--list': + options.listOnly = true; + break; + } + } + + // Validate increment type + if (options.incrementType && !['patch', 'minor', 'major', 'none'].includes(options.incrementType)) { + console.error(`โŒ Invalid increment type: ${options.incrementType}`); + console.error('Valid types: patch, minor, major, none'); + process.exit(1); + } + + const releaser = new NightlyReleaser(); + releaser.releaseFromNightly(options); +} + +module.exports = NightlyReleaser; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4d787c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "playtivity-build-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playtivity-build-tools", + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..399834c --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "playtivity-build-tools", + "version": "1.0.0", + "description": "Local build tools for Playtivity APK with Node.js scripts", + "scripts": { + "build": "node build-apk.js", + "build:patch": "node build-apk.js patch", + "build:minor": "node build-apk.js minor", + "build:major": "node build-apk.js major", + "release": "node release-apk.js", "release:patch": "node release-apk.js patch", + "release:minor": "node release-apk.js minor", + "release:major": "node release-apk.js major", + "release:apk-only": "node release-apk.js --no-bundle", + "release:delete-all": "node delete-releases.js", + "nightly": "node nightly-apk.js", + "nightly:keep-10": "node nightly-apk.js --keep-builds 10", + "nightly:promote": "node nightly-release.js", + "nightly:promote-patch": "node nightly-release.js patch", + "nightly:promote-minor": "node nightly-release.js minor", + "nightly:promote-major": "node nightly-release.js major", "nightly:github": "node nightly-github-release.js", + "nightly:github-stable": "node nightly-github-release.js --stable", + "list": "node list-commands.js", + "help": "node build-apk.js --help", + "help:release": "node release-apk.js --help", + "help:nightly": "node nightly-apk.js --help", + "help:nightly-promote": "node nightly-release.js --help", + "help:nightly-github": "node nightly-github-release.js --help" + }, + "engines": { + "node": ">=14.0.0" + }, "author": "Playtivity Team", + "license": "MIT", + "keywords": [ + "android", + "apk", + "flutter", + "build", + "release", + "nightly", + "github" + ] +} diff --git a/pubspec.lock b/pubspec.lock index 19939cd..e788517 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" matcher: dependency: transitive description: @@ -328,6 +336,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" path: dependency: transitive description: @@ -337,7 +361,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -693,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" workmanager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2d645ea..6de0747 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.1+1 +version: 0.0.2-nightly-20250629-073824+1751153904 environment: sdk: ^3.8.1 @@ -67,9 +67,16 @@ dependencies: # Home screen widgets home_widget: ^0.6.0 - - # Background task execution + # Background task execution workmanager: ^0.6.0 + # Logging framework + logger: ^2.0.2+1 + + # Package information + package_info_plus: ^8.3.0 + + # File system access + path_provider: ^2.1.2 dev_dependencies: flutter_test: diff --git a/release-apk.js b/release-apk.js new file mode 100644 index 0000000..ae5d6ff --- /dev/null +++ b/release-apk.js @@ -0,0 +1,557 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const crypto = require('crypto'); + +class APKReleaser { + constructor() { + this.projectRoot = __dirname; + this.pubspecPath = path.join(this.projectRoot, 'pubspec.yaml'); + this.outputDir = path.join(this.projectRoot, 'releases'); + this.keystorePath = path.join(this.projectRoot, 'release-key.jks'); + this.keystoreBase64Path = path.join(this.projectRoot, 'keystore.base64.txt'); + this.apkPath = path.join(this.projectRoot, 'build', 'app', 'outputs', 'flutter-apk', 'app-release.apk'); + this.bundlePath = path.join(this.projectRoot, 'build', 'app', 'outputs', 'bundle', 'release', 'app-release.aab'); + } + + log(message, type = 'info') { + const timestamp = new Date().toLocaleTimeString(); + const prefix = { + 'info': '๐Ÿ“ฑ', + 'success': 'โœ…', + 'error': 'โŒ', + 'warning': 'โš ๏ธ', + 'build': '๐Ÿ”จ', + 'release': '๐Ÿš€', + 'security': '๐Ÿ”' + }[type] || 'โ„น๏ธ'; + + console.log(`[${timestamp}] ${prefix} ${message}`); + } + + ensureOutputDirectory() { + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + this.log(`Created release 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) { + 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 + }; + } + + getCurrentVersion() { + const content = this.readPubspec(); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('version:')) { + return this.parseVersion(lines[i]); + } + } + + throw new Error('Version line not found in pubspec.yaml'); + } + + incrementVersion(incrementType = 'patch') { + this.log('Reading current version from pubspec.yaml'); + + const content = this.readPubspec(); + const lines = content.split('\n'); + + let versionLineIndex = -1; + let currentVersion = null; + + 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 = 1; // Reset build number for releases + + // Increment based on type + switch (incrementType) { + case 'major': + const [major, minor, patch] = newVersionName.split('.'); + newVersionName = `${parseInt(major) + 1}.0.0`; + break; + case 'minor': + const [maj, min, pat] = newVersionName.split('.'); + newVersionName = `${maj}.${parseInt(min) + 1}.0`; + break; + case 'patch': + const [ma, mi, pa] = newVersionName.split('.'); + newVersionName = `${ma}.${mi}.${parseInt(pa) + 1}`; + break; + case 'none': + // Don't increment version, just reset build number + newBuildNumber = 1; + break; + default: + throw new Error(`Invalid increment type: ${incrementType}`); + } + + const newFullVersion = `${newVersionName}+${newBuildNumber}`; + + lines[versionLineIndex] = `version: ${newFullVersion}`; + this.writePubspec(lines.join('\n')); + + this.log(`Version updated to: ${newFullVersion}`, 'success'); + + return { + versionName: newVersionName, + buildNumber: newBuildNumber, + fullVersion: newFullVersion + }; + } + + setupKeystore() { + this.log('Setting up keystore for release signing', 'security'); + + // Check if keystore already exists + if (fs.existsSync(this.keystorePath)) { + this.log('Keystore already exists, skipping setup'); + return; + } + + // Try to decode from base64 file + if (fs.existsSync(this.keystoreBase64Path)) { + try { + const base64Content = fs.readFileSync(this.keystoreBase64Path, 'utf8').trim(); + const keystoreData = Buffer.from(base64Content, 'base64'); + fs.writeFileSync(this.keystorePath, keystoreData); + this.log('Keystore decoded from base64 file', 'success'); + return; + } catch (error) { + this.log(`Failed to decode keystore from base64: ${error.message}`, 'warning'); + } + } + + throw new Error('No keystore found. Please ensure release-key.jks exists or keystore.base64.txt contains valid base64 data.'); + } + + promptForKeystoreCredentials() { + this.log('Setting up keystore credentials', 'security'); + + const keystorePassword = process.env.ANDROID_KEYSTORE_PASSWORD || 'playtivity123'; + const keyAlias = process.env.ANDROID_KEY_ALIAS || 'playtivity-key'; + const keyPassword = process.env.ANDROID_KEY_PASSWORD || 'playtivity123'; + + // Set environment variables for the build process + process.env.ANDROID_KEYSTORE_PATH = this.keystorePath; + process.env.ANDROID_KEYSTORE_PASSWORD = keystorePassword; + process.env.ANDROID_KEY_ALIAS = keyAlias; + process.env.ANDROID_KEY_PASSWORD = keyPassword; + + this.log('Keystore credentials configured', 'success'); + + return { + keystorePath: this.keystorePath, + keystorePassword, + keyAlias, + keyPassword + }; + } + + runCommand(command, description) { + this.log(`${description}...`, 'build'); + try { + const output = execSync(command, { + cwd: this.projectRoot, + stdio: 'pipe', + encoding: 'utf8', + env: { ...process.env } + }); + this.log(`${description} completed`, 'success'); + return output; + } catch (error) { + this.log(`${description} failed: ${error.message}`, 'error'); + throw error; + } + } + + buildReleaseAPK() { + this.log('Starting release APK build', 'build'); + + // Check if FVM is available + 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 { + // Clean and get dependencies + this.runCommand(`${flutterCommand} clean`, 'Cleaning project'); + this.runCommand(`${flutterCommand} pub get`, 'Getting dependencies'); + + // Run tests + try { + this.runCommand(`${flutterCommand} test`, 'Running tests'); + } catch (error) { + this.log('Tests failed, but continuing with release build', 'warning'); + } + + // Build signed APK + this.runCommand(`${flutterCommand} build apk --release`, 'Building signed release APK'); + + return true; + } catch (error) { + this.log(`Release build failed: ${error.message}`, 'error'); + return false; + } + } + + buildReleaseBundle() { + this.log('Building Android App Bundle (AAB)', 'build'); + + let flutterCommand = 'flutter'; + try { + execSync('fvm --version', { stdio: 'pipe' }); + flutterCommand = 'fvm flutter'; + } catch (error) { + // FVM not available, use system flutter + } + + try { + this.runCommand(`${flutterCommand} build appbundle --release`, 'Building signed release bundle'); + return true; + } catch (error) { + this.log(`Bundle build failed: ${error.message}`, 'warning'); + return false; + } + } + + generateAPKChecksum(apkPath) { + try { + const fileBuffer = fs.readFileSync(apkPath); + const hashSum = crypto.createHash('sha256'); + hashSum.update(fileBuffer); + return hashSum.digest('hex'); + } catch (error) { + this.log(`Failed to generate checksum: ${error.message}`, 'warning'); + return null; + } + } + + verifyAPKSignature(apkPath) { + this.log('Verifying APK signature', 'security'); + + try { + // Try to get APK info using aapt if available + try { + const output = execSync(`aapt dump badging "${apkPath}"`, { + stdio: 'pipe', + encoding: 'utf8' + }); + + const packageMatch = output.match(/package: name='([^']+)'/); + const versionCodeMatch = output.match(/versionCode='([^']+)'/); + const versionNameMatch = output.match(/versionName='([^']+)'/); + + this.log('APK signature verified successfully', 'success'); + + return { + packageName: packageMatch ? packageMatch[1] : 'Unknown', + versionCode: versionCodeMatch ? versionCodeMatch[1] : 'Unknown', + versionName: versionNameMatch ? versionNameMatch[1] : 'Unknown' + }; + } catch (error) { + this.log('aapt not available, skipping detailed APK verification', 'warning'); + return null; + } + } catch (error) { + this.log(`APK verification failed: ${error.message}`, 'error'); + throw error; + } + } + + copyReleaseFiles(version) { + this.log('Copying release files to release directory', 'release'); + + const releaseInfo = { + version: version, + timestamp: new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16), + files: [] + }; + + // Copy APK + if (fs.existsSync(this.apkPath)) { + const apkFileName = `playtivity-v${version.versionName}-release.apk`; + const apkDestPath = path.join(this.outputDir, apkFileName); + + fs.copyFileSync(this.apkPath, apkDestPath); + + const apkInfo = this.getFileInfo(apkDestPath); + const checksum = this.generateAPKChecksum(apkDestPath); + + releaseInfo.files.push({ + type: 'APK', + name: apkFileName, + path: apkDestPath, + size: apkInfo.size, + checksum: checksum + }); + + this.log(`APK copied: ${apkFileName}`, 'success'); + } + + // Copy AAB if exists + if (fs.existsSync(this.bundlePath)) { + const bundleFileName = `playtivity-v${version.versionName}-release.aab`; + const bundleDestPath = path.join(this.outputDir, bundleFileName); + + fs.copyFileSync(this.bundlePath, bundleDestPath); + + const bundleInfo = this.getFileInfo(bundleDestPath); + + releaseInfo.files.push({ + type: 'AAB', + name: bundleFileName, + path: bundleDestPath, + size: bundleInfo.size, + checksum: this.generateAPKChecksum(bundleDestPath) + }); + + this.log(`Bundle copied: ${bundleFileName}`, 'success'); + } + + return releaseInfo; + } + + getFileInfo(filePath) { + try { + const stats = fs.statSync(filePath); + const sizeInMB = (stats.size / (1024 * 1024)).toFixed(2); + return { + size: sizeInMB, + created: stats.birthtime.toLocaleString(), + sizeBytes: stats.size + }; + } catch (error) { + return { size: 'Unknown', created: 'Unknown', sizeBytes: 0 }; + } + } + + generateReleaseNotes(version, releaseInfo) { + const releaseNotesPath = path.join(this.outputDir, `release-notes-v${version.versionName}.md`); + + const notes = `# Playtivity Release v${version.versionName} + +## Release Information +- **Version:** ${version.versionName} +- **Build Number:** ${version.buildNumber} +- **Release Date:** ${new Date().toLocaleDateString()} +- **Build Date:** ${releaseInfo.timestamp} + +## Release Files + +${releaseInfo.files.map(file => ` +### ${file.type} +- **File:** \`${file.name}\` +- **Size:** ${file.size} MB +- **SHA256:** \`${file.checksum || 'N/A'}\` +`).join('\n')} + +## Installation Instructions + +### APK Installation +1. Enable "Install from unknown sources" in Android settings +2. Download the APK file: \`${releaseInfo.files.find(f => f.type === 'APK')?.name || 'N/A'}\` +3. Open the downloaded file and follow installation prompts + +### Using ADB +\`\`\`bash +adb install "${releaseInfo.files.find(f => f.type === 'APK')?.name || 'playtivity-release.apk'}" +\`\`\` + +## Verification +You can verify the integrity of the downloaded files using their SHA256 checksums listed above. + +## Changes in this Release + +- Release build with proper signing +- Performance optimizations +- Bug fixes and improvements + +--- +*Generated automatically by Playtivity Release Builder* +`; + + fs.writeFileSync(releaseNotesPath, notes, 'utf8'); + this.log(`Release notes generated: ${releaseNotesPath}`, 'success'); + + return releaseNotesPath; + } + + async release(incrementType = 'patch', buildBundle = true) { + try { + this.log('๐Ÿš€ Starting Playtivity Release Process', 'release'); + + // Setup + this.ensureOutputDirectory(); + this.setupKeystore(); + this.promptForKeystoreCredentials(); + + // Version management + const newVersion = this.incrementVersion(incrementType); + + // Build APK + const apkSuccess = this.buildReleaseAPK(); + if (!apkSuccess) { + throw new Error('APK build failed'); + } + + // Verify APK + if (fs.existsSync(this.apkPath)) { + this.verifyAPKSignature(this.apkPath); + } + + // Build AAB (optional) + if (buildBundle) { + this.buildReleaseBundle(); + } + + // Copy and organize release files + const releaseInfo = this.copyReleaseFiles(newVersion); + + // Generate release notes + const releaseNotesPath = this.generateReleaseNotes(newVersion, releaseInfo); + + // Success summary + this.log('๐ŸŽ‰ Release build completed successfully!', 'success'); + + console.log('\n๐Ÿ“‹ Release Summary:'); + console.log(` Version: ${newVersion.fullVersion}`); + console.log(` Release Directory: ${this.outputDir}`); + console.log(` Release Notes: ${releaseNotesPath}`); + + console.log('\n๐Ÿ“ฑ Release Files:'); + releaseInfo.files.forEach(file => { + console.log(` ${file.type}: ${file.name} (${file.size} MB)`); + }); + + console.log('\n๐Ÿ” Security:'); + console.log(' โœ… APK signed with release keystore'); + console.log(' โœ… SHA256 checksums generated'); + + console.log('\n๐Ÿ“ค Next Steps:'); + console.log(' 1. Test the release APK on a real device'); + console.log(' 2. Upload AAB to Google Play Console (if available)'); + console.log(' 3. Create GitHub release with generated files'); + console.log(' 4. Update release notes with actual changes'); + + } catch (error) { + this.log(`Release process failed: ${error.message}`, 'error'); + process.exit(1); + } + } +} + +// CLI interface +function showHelp() { + console.log(` +๐Ÿš€ Playtivity Release Builder + +Usage: node release-apk.js [increment-type] [options] + +Increment Types: + patch (default) - Increment patch version (0.0.1 โ†’ 0.0.2) + minor - Increment minor version (0.1.0 โ†’ 0.2.0) + major - Increment major version (1.0.0 โ†’ 2.0.0) + none - Don't increment version, just build release + +Options: + --no-bundle - Skip building Android App Bundle (AAB) + --help, -h - Show this help message + +Examples: + node release-apk.js # Increment patch, build APK + AAB + node release-apk.js minor # Increment minor version + node release-apk.js --no-bundle # Build APK only, no AAB + node release-apk.js none # Build release without version increment + +Environment Variables: + ANDROID_KEYSTORE_PASSWORD - Keystore password (default: playtivity123) + ANDROID_KEY_ALIAS - Key alias (default: playtivity-key) + ANDROID_KEY_PASSWORD - Key password (default: playtivity123) + +The script will: + โœ… Set up keystore for release signing + โœ… Increment version in pubspec.yaml + โœ… Build signed release APK + โœ… Build signed release AAB (optional) + โœ… Generate checksums and release notes + โœ… Organize files in releases/ folder +`); +} + +// 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.find(arg => !arg.startsWith('--')) || 'patch'; + const buildBundle = !args.includes('--no-bundle'); + + const validTypes = ['patch', 'minor', 'major', 'none']; + + if (!validTypes.includes(incrementType)) { + console.error(`โŒ Invalid increment type: ${incrementType}`); + console.error(`Valid types: ${validTypes.join(', ')}`); + process.exit(1); + } + + const releaser = new APKReleaser(); + releaser.release(incrementType, buildBundle); +} + +module.exports = APKReleaser; diff --git a/test-release-notes.js b/test-release-notes.js new file mode 100644 index 0000000..e69de29