From 4b80590f941f49bb64a6900204bba4c9625ea549 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Fri, 13 Jun 2025 05:13:25 +0800 Subject: [PATCH 01/10] fix not logging in --- lib/widgets/spotify_webview_login.dart | 50 ++++++++++++++++++++------ 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/lib/widgets/spotify_webview_login.dart b/lib/widgets/spotify_webview_login.dart index 121146a..fbf1b83 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -1,11 +1,6 @@ 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'; class SpotifyWebViewLogin extends StatefulWidget { final Function(String, Map) onAuthComplete; // Bearer access token and headers @@ -25,9 +20,8 @@ class _SpotifyWebViewLoginState extends State { bool _isLoading = true; String? _error; Map _extractedHeaders = {}; - final bool _spDcDetected = false; - String _currentUrl = ''; bool _showOverlay = true; + String _currentUrl = ''; @override Widget build(BuildContext context) { @@ -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, @@ -223,12 +216,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 + print('๐ŸŒ WebView Console [${consoleMessage.messageLevel}]: ${consoleMessage.message}'); }, ), From 104886436f97c2c5640a14b8d824fd126b64c726 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Sat, 14 Jun 2025 04:13:49 +0800 Subject: [PATCH 02/10] improve widget feel --- .../com/mliem/playtivity/MainActivity.kt | 19 ++--- .../playtivity/widget/ImageCacheService.kt | 16 ++-- .../widget/PlaytivityWidgetProvider.kt | 48 ++++------- lib/providers/spotify_provider.dart | 16 ++-- lib/services/widget_service.dart | 84 +++++++++---------- 5 files changed, 82 insertions(+), 101 deletions(-) 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..46c571d 100644 --- a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt @@ -32,32 +32,25 @@ class MainActivity : FlutterActivity() { } } } - } - - private fun updateWidget() { + } 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) + // Reduced delay for faster widget updates + delay(50) - // Log both SharedPreferences to see where data is being saved + // Reduced logging for better performance 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") + android.util.Log.d("PlaytivityWidget", "Widget update triggered with $flutterActivitiesCount activities") // 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") + android.util.Log.d("PlaytivityWidget", "Widget update completed") } } catch (e: Exception) { android.util.Log.e("PlaytivityWidget", "Error updating widget", e) 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..488225f 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 @@ -27,21 +27,24 @@ class ImageCacheService : IntentService("ImageCacheService") { } } - private fun cacheAllFriendImages() { - try { + private fun cacheAllFriendImages() { try { android.util.Log.d("ImageCacheService", "Starting to cache friend images") val prefs = getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE) val activitiesCount = prefs.getString("activities_count", "0")?.toIntOrNull() ?: 0 runBlocking { + var cachedCount = 0 // Cache images for all friends (no longer limited to 5) 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") + // Only log first few to reduce noise + if (i < 2) { + android.util.Log.d("ImageCacheService", "Caching image for friend $i: $friendName") + } val cachedPath = ImageDownloader.downloadAndCacheImage(this@ImageCacheService, friendImage, i) if (cachedPath != null) { @@ -49,9 +52,12 @@ class ImageCacheService : IntentService("ImageCacheService") { prefs.edit() .putString("friend_${i}_cached_image", cachedPath) .apply() - android.util.Log.d("ImageCacheService", "Cached image path saved: $cachedPath") + cachedCount++ } - } + } } + + if (cachedCount > 0) { + android.util.Log.d("ImageCacheService", "Cached $cachedCount images total") } } 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..1604172 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 @@ -107,44 +107,22 @@ class PlaytivityAppWidget : GlanceAppWidget() { 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 + // 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}") + // Reduced logging for better performance + android.util.Log.d("PlaytivityWidget", "Reading widget data: activitiesCount=$activitiesCount, lastUpdate=$lastUpdate") - // 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)") + // Only log if there are issues or in debug builds + if (activitiesCount == 0) { + android.util.Log.d("PlaytivityWidget", "No activities found") + } else if (activitiesCount > 0) { + android.util.Log.d("PlaytivityWidget", "Processing $activitiesCount activities") } - // 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") - } - } - android.util.Log.d("PlaytivityWidget", "Found $foundAdditionalFriends additional friends beyond activitiesCount") - Scaffold( titleBar = { Box( @@ -248,12 +226,16 @@ class PlaytivityAppWidget : GlanceAppWidget() { 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 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") + // Only log invalid activities or first few activities to reduce noise + if (!isValid && index < 5) { + android.util.Log.d("PlaytivityWidget", "Activity $index: invalid data") + } else if (isValid && index < 2) { + android.util.Log.d("PlaytivityWidget", "Activity $index: $friendName - $friendTrack") + } if (isValid) { FriendActivity( diff --git a/lib/providers/spotify_provider.dart b/lib/providers/spotify_provider.dart index 9efdb65..be2525c 100644 --- a/lib/providers/spotify_provider.dart +++ b/lib/providers/spotify_provider.dart @@ -170,14 +170,16 @@ class SpotifyProvider extends ChangeNotifier { print('๐Ÿ”„ Attempting to fetch friend activities with Bearer token...'); try { // Use fast load when showing skeleton to avoid slow API calls - final useFastLoad = showSkeleton; - activities = await _buddyService.getFriendActivity( + final useFastLoad = showSkeleton; activities = await _buddyService.getFriendActivity( fastLoad: useFastLoad, onActivitiesUpdate: (updatedActivities) { // Update activities progressively as track durations are fetched _friendsActivities = updatedActivities; notifyListeners(); - print('๐Ÿ”„ Progressive update: ${updatedActivities.length} activities updated'); + // Reduced logging frequency for progressive updates + if (updatedActivities.length <= 5 || updatedActivities.length % 10 == 0) { + print('๐Ÿ”„ Progressive update: ${updatedActivities.length} activities updated'); + } }, ); if (activities.isNotEmpty) { @@ -323,15 +325,17 @@ class SpotifyProvider extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 500)); try { - print('๐Ÿ”„ Enhancing activities with detailed info...'); - // Load detailed activities (with duration checks) + print('๐Ÿ”„ Enhancing activities with detailed info...'); // Load detailed activities (with duration checks) final detailedActivities = await _buddyService.getFriendActivity( fastLoad: false, // Full load with duration checks onActivitiesUpdate: (updatedActivities) { // 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) { + print('๐Ÿ”„ Background enhancement update: ${updatedActivities.length} activities updated'); + } }, ); diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart index a3add65..d3f6c58 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -2,11 +2,12 @@ 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'; @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'; @@ -24,10 +25,8 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; @pragma("vm:entry-point") static FutureOr backgroundCallback(Uri? data) async { print('๐ŸŽฏ Widget callback triggered with data: $data'); - - if (data != null) { + if (data != null) { final action = data.host; - final queryParams = data.queryParameters; switch (action) { case 'openApp': @@ -42,13 +41,15 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; } } } - // Save data directly to SharedPreferences as fallback static Future _saveToSharedPreferences(String key, String value) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('flutter.$key', value); - print('๐Ÿ“Š Direct save: flutter.$key = $value'); + // Only log key operations to reduce noise + if (key == 'activities_count' || key == 'last_update') { + print('๐Ÿ“Š Widget data saved: flutter.$key = $value'); + } } catch (e) { print('โŒ Error saving to SharedPreferences: $e'); } @@ -60,16 +61,16 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; List? friendsActivities, }) async { try { - - // Save friends' activities (show all activities, no longer limited to 5) + // 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', ''); @@ -90,36 +91,32 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; await _saveToSharedPreferences('friend_${i}_is_currently_playing', ''); await _saveToSharedPreferences('friend_${i}_activity_type', ''); } - - // Now save all the actual activities + // 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}'); + // Reduce individual activity logging + if (i < 3) { // Only log first 3 activities to reduce noise + 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 +125,21 @@ 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'}'); + print('๐Ÿ“Š Widget: Saved activities_count as ${activities.length}'); - } else { - print('๐Ÿ“Š Widget: No activities to save'); + if (activities.length <= 3) { + print('๐Ÿ“Š Widget: Successfully saved all ${activities.length} activities'); + } else { + print('๐Ÿ“Š Widget: Successfully saved ${activities.length} activities (showing first 3 in logs)'); + } + } else { + print('๐Ÿ“Š Widget: No activities to save - clearing widget data'); // 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', ''); @@ -162,18 +161,17 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; await _saveToSharedPreferences('friend_${i}_activity_type', ''); } } - - // Save last update timestamp + // 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)); + // Reduced delay for faster widget updates + await Future.delayed(const Duration(milliseconds: 100)); print('๐Ÿ“Š Widget: About to call updateWidget()'); print('๐Ÿ“Š Widget: Using androidName: $_androidWidgetName'); - // Trigger widget update via method channel + // Trigger widget update via method channel try { final result = await _channel.invokeMethod('updateWidget'); print('๐Ÿ“ฑ Widget update triggered via method channel: $result'); @@ -201,16 +199,15 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; return false; } } - - // Clear widget data + // Clear widget data static Future clearWidgetData() async { try { // Clear activities count 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', ''); @@ -230,7 +227,6 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; print('โŒ Error clearing widget data: $e'); } } - // Debug method to test widget data and update static Future debugWidgetData() async { try { @@ -243,17 +239,17 @@ class WidgetService { static const String _widgetName = 'PlaytivityWidget'; final activitiesCount = prefs.getString('flutter.activities_count') ?? 'null'; print('๐Ÿ“Š 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'); } - if (count > 20) { - print('๐Ÿ“Š ... and ${count - 20} more activities'); + if (count > 5) { + print('๐Ÿ“Š ... and ${count - 5} more activities'); } // Test widget update via method channel From c2747dd4189611c36f8badebbd882f45df5e53dd Mon Sep 17 00:00:00 2001 From: mliem2k Date: Mon, 16 Jun 2025 04:52:59 +0800 Subject: [PATCH 03/10] update logging to not use print --- .gitignore | 4 +- BUILD_README.md | 627 +++++++++++++++++++++++++ build-apk.js | 319 +++++++++++++ lib/services/app_logger.dart | 79 ++++ lib/services/background_service.dart | 56 +-- lib/services/http_interceptor.dart | 6 +- lib/utils/auth_utils.dart | 34 +- lib/utils/spotify_launcher.dart | 19 +- list-commands.js | 83 ++++ nightly-apk.js | 614 ++++++++++++++++++++++++ nightly-github-release.js | 342 ++++++++++++++ nightly-release.js | 677 +++++++++++++++++++++++++++ package.json | 41 ++ pubspec.lock | 8 + pubspec.yaml | 8 +- release-apk.js | 557 ++++++++++++++++++++++ test-release-notes.js | 0 17 files changed, 3407 insertions(+), 67 deletions(-) create mode 100644 BUILD_README.md create mode 100644 build-apk.js create mode 100644 lib/services/app_logger.dart create mode 100644 list-commands.js create mode 100644 nightly-apk.js create mode 100644 nightly-github-release.js create mode 100644 nightly-release.js create mode 100644 package.json create mode 100644 release-apk.js create mode 100644 test-release-notes.js diff --git a/.gitignore b/.gitignore index 7278a41..48d063a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ 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 diff --git a/BUILD_README.md b/BUILD_README.md new file mode 100644 index 0000000..d0af80c --- /dev/null +++ b/BUILD_README.md @@ -0,0 +1,627 @@ +# 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) + +### 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/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/lib/services/app_logger.dart b/lib/services/app_logger.dart new file mode 100644 index 0000000..b7e0705 --- /dev/null +++ b/lib/services/app_logger.dart @@ -0,0 +1,79 @@ +import 'package:logger/logger.dart'; + +/// 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 + printTime: false, // Should each log print contain a timestamp + ), + 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..1ba9700 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"; @@ -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..6a8ab20 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 { @@ -52,11 +53,10 @@ class HttpInterceptor { rethrow; } } - - /// Handle response and check for authentication errors + /// 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'); + AppLogger.http('HTTP ${response.statusCode} detected - redirecting to login'); // Use the AuthUtils to handle the authentication error // This will log out the user and navigate to login screen diff --git a/lib/utils/auth_utils.dart b/lib/utils/auth_utils.dart index 576815d..3549f2d 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,9 +34,8 @@ 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(); @@ -45,17 +45,16 @@ class AuthUtils { 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 +71,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 +90,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) { @@ -106,10 +103,10 @@ class AuthUtils { return true; } - /// Handles 401/403 errors by immediately navigating to login screen + /// Handles 401/403 errors by immediately navigating to login screen /// 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(); @@ -126,9 +123,8 @@ class AuthUtils { duration: const Duration(seconds: 2), ), ); - } - } catch (e) { - print('โŒ Error handling authentication error: $e'); + } } catch (e) { + AppLogger.error('Error handling authentication error', e); // Fallback: try to navigate directly if (context.mounted) { 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/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..33f59c1 --- /dev/null +++ b/nightly-apk.js @@ -0,0 +1,614 @@ +#!/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 + }; + } + + 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(`Base version: ${currentVersion.fullVersion}`); + + // Create nightly version with date and time + const now = new Date(); + const dateStr = now.toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD + const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS + + // Format: base-version-nightly-YYYYMMDD-HHMMSS+build + const nightlyVersionName = `${currentVersion.versionName}-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 + }; + } + + 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'); + + 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 nightly build', 'warning'); + } + + // Build APK with nightly configuration + this.runCommand(`${flutterCommand} build apk --release --dart-define=BUILD_TYPE=nightly`, 'Building nightly APK'); + + return true; + } catch (error) { + this.log(`Nightly 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: versionInfo.original, + 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..d7ba9fd --- /dev/null +++ b/nightly-github-release.js @@ -0,0 +1,342 @@ +#!/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) { + releaseNotes += ` +- **Version**: ${buildInfo.version?.fullVersion || buildInfo.version?.versionName || 'Unknown'} +- **Base Version**: ${buildInfo.baseVersion?.fullVersion || buildInfo.baseVersion?.versionName || 'Unknown'} + +## 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 + +1. Download the APK file from the assets below +2. Enable "Install from unknown sources" in your Android settings +3. Install the APK file + +## What's New in This Nightly + +This nightly build includes all commits up to ${buildInfo?.git?.commit?.substring(0, 7) || 'unknown'}. + +**Note**: This is an automated nightly release. For stable releases, please see the main releases page. + +--- +*Built automatically from the latest development code* +`; + + return releaseNotes; + } + + 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.json b/package.json new file mode 100644 index 0000000..a5fc6ba --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "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", + "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..01e4dae 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: diff --git a/pubspec.yaml b/pubspec.yaml index 2d645ea..1b9e8ec 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.1-nightly-20250615-030600+1750014360 environment: sdk: ^3.8.1 @@ -67,9 +67,11 @@ 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 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 From a8d061a3b742345a7383855ca49be9b4e7968ae3 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Wed, 18 Jun 2025 23:43:26 +0800 Subject: [PATCH 04/10] add inapp updater --- android/app/build.gradle.kts | 49 +- android/app/src/main/AndroidManifest.xml | 19 +- .../com/mliem/playtivity/MainActivity.kt | 56 +- android/app/src/main/res/xml/file_paths.xml | 14 + lib/main.dart | 191 +++++- lib/screens/home_screen.dart | 228 +++---- lib/screens/profile_screen.dart | 75 +-- lib/screens/settings_screen.dart | 171 ++++- lib/services/update_service.dart | 610 ++++++++++++++++++ lib/utils/update_launcher.dart | 75 +++ lib/utils/version_utils.dart | 186 ++++++ lib/widgets/spotify_webview_login.dart | 50 +- pubspec.lock | 26 +- pubspec.yaml | 9 +- 14 files changed, 1497 insertions(+), 262 deletions(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 lib/services/update_service.dart create mode 100644 lib/utils/update_launcher.dart create mode 100644 lib/utils/version_utils.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0816e6d..9124144 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_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.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/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c6205fb..e92418b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,12 +17,23 @@ - - - + android:label="Playtivity" + android:usesCleartextTraffic="true"> + + + + + + + // Widget channel for home screen widget updates + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WIDGET_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "updateWidget" -> { updateWidget() @@ -32,6 +33,23 @@ class MainActivity : FlutterActivity() { } } } + + // 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) + } + } + else -> { + result.notImplemented() + } + } + } } private fun updateWidget() { try { // Use direct Glance updateAll approach @@ -56,4 +74,36 @@ class MainActivity : FlutterActivity() { android.util.Log.e("PlaytivityWidget", "Error updating widget", e) } } + + // Handle APK installation using FileProvider + private fun installApk(filePath: String, result: MethodChannel.Result) { + try { + val file = java.io.File(filePath) + if (!file.exists()) { + result.error("FILE_NOT_FOUND", "APK file not found at $filePath", null) + return + } + + android.util.Log.d("Playtivity", "Installing APK from $filePath") + // Create content URI using FileProvider + val contentUri = androidx.core.content.FileProvider.getUriForFile( + this, + "${packageName}.fileprovider", + file + ) + + // 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 + } + + // Start the installation activity + startActivity(intent) + result.success(true) + } catch (e: Exception) { + android.util.Log.e("Playtivity", "Error installing APK", e) + result.error("INSTALLATION_ERROR", "Error installing APK: ${e.message}", null) + } + } } 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/lib/main.dart b/lib/main.dart index 111ae7d..798d332 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ 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'; @@ -25,14 +26,36 @@ 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 { + // Check if we should check for updates + if (await UpdateService.shouldCheckForUpdates()) { + try { + // 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 + print('Update available: ${updateResult.updateInfo?.version}'); + } + } catch (e) { + // Ignore errors during startup, we don't want to block app launch + print('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,6 +71,8 @@ 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) { @@ -103,22 +128,23 @@ class _AppWrapperState extends State { print(' - isAuthenticated: ${authProvider.isAuthenticated}'); print(' - currentUser: ${authProvider.currentUser?.displayName ?? 'null'}'); print(' - bearerToken exists: ${authProvider.bearerToken != null}'); - - // Show loading screen while authentication is being initialized or in progress + // Show loading screen while authentication is being initialized or in progress if (!authProvider.isInitialized || authProvider.isLoading) { print('๐Ÿ“ฑ 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), + ), + ], + ), ), ), ); @@ -238,3 +264,140 @@ 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) { + setState(() { + _updateInfo = updateResult.updateInfo; + }); + + // If auto-download is enabled, download immediately + if (autoDownload && mounted) { + _handleUpdateDownload(); + } + } + } catch (e) { + print('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.withOpacity(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/screens/home_screen.dart b/lib/screens/home_screen.dart index a3a3078..3c94d41 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -101,30 +101,29 @@ class _HomeScreenState extends State { ), ), ), - ), - 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 +142,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 +249,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/profile_screen.dart b/lib/screens/profile_screen.dart index 80d8357..a49bfc1 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -51,26 +51,7 @@ 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'); - } + print('โš ๏ธ No authentication available - cannot load profile data'); } } @override @@ -101,26 +82,26 @@ class _ProfileScreenState extends State with TickerProviderStateM ), ), ), - ), - 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 @@ -199,10 +180,10 @@ class _ProfileScreenState extends State with TickerProviderStateM _buildTopArtistsTab(spotifyProvider), ], ), - ), - ], + ), ], ); }, + ), ), ); } @@ -223,10 +204,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 +233,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..e0e03ee 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,8 +1,11 @@ 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'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -15,11 +18,11 @@ class SettingsScreen extends StatelessWidget { 'Settings', style: TextStyle(fontWeight: FontWeight.bold), ), - ), - body: Consumer2( - builder: (context, themeProvider, authProvider, child) { - return ListView( - padding: const EdgeInsets.all(16), + ), body: SafeArea( + child: Consumer2( + builder: (context, themeProvider, authProvider, child) { + return ListView( + padding: const EdgeInsets.all(16), children: [ // Appearance Section Card( @@ -105,6 +108,27 @@ class SettingsScreen extends StatelessWidget { ), ), + 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), + ], + ), + ), + const SizedBox(height: 16), // About Section @@ -120,14 +144,23 @@ class SettingsScreen extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - ), ListTile( - leading: Image.asset( - 'assets/images/playtivity_logo_small_icon.png', - width: 24, - height: 24, - ), - title: const Text('Playtivity'), - subtitle: const Text('Version 0.0.1'), + ), + 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'), + ); + }, ), const ListTile( leading: Icon(Icons.info_outline), @@ -136,13 +169,113 @@ class SettingsScreen extends StatelessWidget { ), ], ), - ), - ], + ), ], ); }, + ), ), ); - } void _showLogoutDialog(BuildContext context) { + } + // Build widget for update preferences + Widget _buildUpdatePreferencesTile(BuildContext context) { + return FutureBuilder( + future: UpdateService.getNightlyBuildPreference(), + builder: (context, snapshot) { + final isNightlyEnabled = snapshot.data ?? false; + + return StatefulBuilder( + builder: (context, setState) { + 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 { + await UpdateService.setNightlyBuildPreference(value); + setState(() {}); // Refresh the widget + + if (value && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nightly builds enabled. Check for updates to get the latest version.')) + ); + } + }, + ), + ); + } + ); + }, + ); + } + + // 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), + ); + } + + // Handle the update check process + Future _checkForUpdates(BuildContext context) async { + // Show loading indicator + final scaffoldMessenger = ScaffoldMessenger.of(context); + + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('Checking for updates...')) + ); + + // Check for updates + final updateResult = await UpdateService.checkForUpdates(forceCheck: true); + + // Hide the loading snackbar + scaffoldMessenger.hideCurrentSnackBar(); + + if (!context.mounted) return; + + // Handle the update result + if (updateResult.hasUpdate && updateResult.updateInfo != null) { + // Show the update dialog + final shouldDownload = await UpdateService.showUpdateDialog( + context, + updateResult.updateInfo!, + ); + + 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 + scaffoldMessenger.showSnackBar( + SnackBar(content: Text('Error: ${updateResult.error}')) + ); + } else { + // No updates available + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('You are already on the latest version!')) + ); + } + } + + void _showLogoutDialog(BuildContext context) { showDialog( context: context, builder: (BuildContext dialogContext) { @@ -171,7 +304,9 @@ class SettingsScreen extends StatelessWidget { ); }, ); - } Future _performLogout(BuildContext context) async { + } + + Future _performLogout(BuildContext context) async { print('๐Ÿšช Starting logout process...'); final authProvider = context.read(); @@ -196,4 +331,4 @@ class SettingsScreen extends StatelessWidget { print('โš ๏ธ 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/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..f6709c5 --- /dev/null +++ b/lib/services/update_service.dart @@ -0,0 +1,610 @@ +import 'dart:convert'; +import 'dart:io'; +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/mliem/playtivity/main'; + static const String _githubReleasesApi = 'https://api.github.com/repos/mliem/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 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(); + final useNightly = await getNightlyBuildPreference(); + + AppLogger.info('Checking for updates...'); + AppLogger.info('Current version: ${currentVersion.version}+${currentVersion.buildNumber}'); + 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 + // Prefer nightly if user opted in and it's newer than release + if (useNightly && nightlyCheck != null && nightlyCheck.hasUpdate) { + AppLogger.info('Nightly update available: ${nightlyCheck.updateInfo?.version}'); + return nightlyCheck; + } + + // Otherwise return the release update if available + 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 release (first one in the list is the latest) + final latestReleaseJson = releasesJson.isNotEmpty ? releasesJson[0] : null; + if (latestReleaseJson == null) { + return UpdateCheckResult( + hasUpdate: false, + isNightly: false, + updateInfo: null, + error: 'No 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: 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 the nightly information + final response = await http.get(Uri.parse('$_baseUrl/$_nightlyInfoPath')); + + if (response.statusCode != 200) { + return UpdateCheckResult( + hasUpdate: false, + isNightly: true, + updateInfo: null, + error: 'Failed to fetch nightly info: HTTP ${response.statusCode}', + ); + } + + final nightlyJson = json.decode(response.body); + final nightlyInfo = UpdateInfo.fromJson(nightlyJson, isNightly: true); + _latestNightlyInfo = nightlyInfo; + + // For nightlies, we compare build dates - nightlies have a buildDate field + final hasUpdate = VersionUtils.isNewerNightly( + currentVersion: currentVersion.version, + newVersion: nightlyInfo.version, + newBuildTime: nightlyInfo.buildDate, + ); + + 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()}', + ); + } + } + + // Download an update file + static Future downloadUpdate(UpdateInfo updateInfo) 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); + + // Download the file + final response = await http.get(Uri.parse(updateInfo.apkUrl)); + + if (response.statusCode != 200) { + return UpdateDownloadResult( + success: false, + filePath: null, + error: 'Failed to download update: HTTP ${response.statusCode}', + ); + } + + // Write the file + await file.writeAsBytes(response.bodyBytes); + + 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; + } + + // Install a downloaded update + static Future installUpdate(String filePath) async { + try { + AppLogger.info('Installing update: $filePath'); + return await UpdateLauncher.installApk(filePath); + } catch (e) { + AppLogger.error('Error installing update', e); + return false; + } + } + + // Show a download progress dialog + static Future showDownloadDialog(BuildContext context, UpdateInfo updateInfo) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return FutureBuilder( + future: downloadUpdate(updateInfo), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return AlertDialog( + title: const Text('Downloading Update'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Please wait while we download the update...'), + ], + ), + ); + } else { + if (snapshot.hasError || (snapshot.data != null && !snapshot.data!.success)) { + return AlertDialog( + title: const Text('Download Failed'), + content: Text('Failed to download the update: ${snapshot.error ?? snapshot.data?.error ?? 'Unknown error'}'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(null); + }, + child: const Text('OK'), + ), + ], + ); + } else { + // Auto-close the dialog and return the path + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(snapshot.data!.filePath); + }); + + return const AlertDialog( + title: Text('Download Complete'), + content: Text('Update downloaded successfully!'), + ); + } + } + }, + ); + }, + ); + } + + // 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: const Text('The update has been downloaded and is ready to install. The app will close during installation.'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final success = await installUpdate(filePath); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to install update. Please try again.')), + ); + } + if (context.mounted) { + Navigator.of(context).pop(success); + } + }, + 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 version = json['tag_name'] as String; + + // 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; + + // Parse release date + DateTime buildDate; + try { + buildDate = DateTime.parse(json['published_at'] as String); + } catch (_) { + buildDate = DateTime.now(); + } + + // Extract build number from release name if possible + String buildNumber = ''; + final name = json['name'] as String? ?? version; + final buildMatch = RegExp(r'build\s*(\d+)').firstMatch(name); + if (buildMatch != null) { + buildNumber = buildMatch.group(1) ?? ''; + } + + return UpdateInfo( + version: version.startsWith('v') ? version.substring(1) : version, // Remove 'v' prefix if present + 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, + }); +} diff --git a/lib/utils/update_launcher.dart b/lib/utils/update_launcher.dart new file mode 100644 index 0000000..793ee51 --- /dev/null +++ b/lib/utils/update_launcher.dart @@ -0,0 +1,75 @@ +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'); + + /// 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; + } + + // Log information + AppLogger.info('Installing APK from: $filePath'); + + // For Android, use platform-specific code to install the APK + try { + // Try using the platform channel first + await platform.invokeMethod('installApk', {'filePath': filePath}); + return true; + } on PlatformException catch (e) { + // If platform channel fails, fallback to opening the file with Intent + AppLogger.warning('Platform channel failed, falling back to file intent: ${e.message}'); + + // Convert file path to URI and launch it + final uri = Uri.file(filePath); + return await _launchFileIntent(uri); + } + } catch (e) { + AppLogger.error('Error installing APK', e); + return false; + } + } + + /// Launches a URI to open a file with the appropriate intent. + /// + /// Used for APK installation when the platform channel is not available. + static Future _launchFileIntent(Uri uri) async { + try { + // For APKs, we need to add the mime type and ensure proper intent flags + final canLaunch = await canLaunchUrl(uri); + + if (!canLaunch) { + AppLogger.error('Cannot launch file: ${uri.toString()}'); + return false; + } + + // Launch with specific parameters for APK files + return await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + AppLogger.error('Error launching file intent', e); + return false; + } + } +} diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart new file mode 100644 index 0000000..7f96ed0 --- /dev/null +++ b/lib/utils/version_utils.dart @@ -0,0 +1,186 @@ +import 'package:flutter/foundation.dart'; + +/// Utility class for comparing version strings and handling +/// version-related functionality. +class VersionUtils { + /// 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, + }) { + // 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 release, prefer the release + return true; + } else if (!currentVersion.contains('nightly') && newVersion.contains('nightly')) { + // If current is release but new is nightly, don't update unless explicitly requested + return false; + } + + // Strip any build metadata (anything after +) + final currentClean = currentVersion.split('+')[0]; + final newClean = newVersion.split('+')[0]; + + // Split versions into parts (split by periods or dashes) + final currentParts = currentClean.split('-'); + final newParts = newClean.split('-'); + + // Compare base version parts (x.y.z) + final currentBaseParts = currentParts[0].split('.'); + final newBaseParts = newParts[0].split('.'); + + // Normalize to have at least 3 parts (x.y.z) + while (currentBaseParts.length < 3) currentBaseParts.add('0'); + while (newBaseParts.length < 3) newBaseParts.add('0'); + + // Compare major.minor.patch parts + for (int i = 0; i < 3; i++) { + final current = int.tryParse(currentBaseParts[i]) ?? 0; + final next = int.tryParse(newBaseParts[i]) ?? 0; + + if (next > current) return true; + if (next < current) return false; + } + + // If 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 (currentParts.length == 1 && newParts.length > 1) return false; + if (currentParts.length > 1 && newParts.length == 1) return true; + + // If both have pre-release parts, compare them + if (currentParts.length > 1 && newParts.length > 1) { + return newParts[1].compareTo(currentParts[1]) > 0; + } + + // Versions are equal + return false; + } + + /// 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, + }) { + // Extract the build timestamp from the version string if possible + // Nightly versions typically have format: x.y.z-nightly-YYYYMMDD-HHMMSS + DateTime? currentBuildTime; + + try { + if (currentVersion.contains('nightly')) { + final parts = currentVersion.split('-nightly-'); + if (parts.length > 1) { + final dateTimePart = parts[1].split('+')[0]; + + // Try to parse the date/time part + if (dateTimePart.length >= 8) { + // Format is YYYYMMDD or YYYYMMDD-HHMMSS + final datePart = dateTimePart.substring(0, 8); + final year = int.parse(datePart.substring(0, 4)); + final month = int.parse(datePart.substring(4, 6)); + final day = int.parse(datePart.substring(6, 8)); + + int hour = 0, minute = 0, second = 0; + if (dateTimePart.length >= 15 && dateTimePart.contains('-')) { + final timePart = dateTimePart.substring(9, 15); + hour = int.parse(timePart.substring(0, 2)); + minute = int.parse(timePart.substring(2, 4)); + second = int.parse(timePart.substring(4, 6)); + } + + currentBuildTime = DateTime(year, month, day, hour, minute, second); + } + } + } + } catch (e) { + debugPrint('Error parsing current nightly version: $e'); + // Fall back to normal version comparison + } + + // If we can't determine build time from version string, compare normal versions + if (currentBuildTime == null) { + return isNewerVersion(currentVersion: currentVersion, newVersion: newVersion); + } + + // Compare build times + return newBuildTime.isAfter(currentBuildTime); + } + + /// 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/spotify_webview_login.dart b/lib/widgets/spotify_webview_login.dart index fbf1b83..e2b686a 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -43,31 +43,31 @@ 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).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), + ), ), - ), - ], + ], + ), ), - ), // Error display if (_error != null) @@ -322,11 +322,11 @@ class _SpotifyWebViewLoginState extends State { ], ), ), - ), - ], + ), ], ), ), ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 01e4dae..e788517 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -336,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: @@ -345,7 +361,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -701,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 1b9e8ec..7ece78a 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-nightly-20250615-030600+1750014360 +version: 0.0.1+1 environment: sdk: ^3.8.1 @@ -69,9 +69,14 @@ dependencies: home_widget: ^0.6.0 # 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: From 14d4f5d304b45a6ef5498281a5cbf6928406140d Mon Sep 17 00:00:00 2001 From: mliem2k Date: Thu, 19 Jun 2025 03:04:27 +0800 Subject: [PATCH 05/10] update fetching version from github correctly --- BUILD_README.md | 1 + android/app/build.gradle.kts | 6 +- android/gradle.properties | 4 + lib/providers/auth_provider.dart | 59 +- lib/providers/spotify_provider.dart | 8 + lib/screens/settings_screen.dart | 742 +++++++++++++++++------- lib/services/http_interceptor.dart | 26 +- lib/services/spotify_buddy_service.dart | 15 +- lib/services/update_service.dart | 159 ++++- lib/utils/auth_utils.dart | 15 +- package-lock.json | 16 + pubspec.yaml | 2 +- 12 files changed, 806 insertions(+), 247 deletions(-) create mode 100644 package-lock.json diff --git a/BUILD_README.md b/BUILD_README.md index d0af80c..dc25afe 100644 --- a/BUILD_README.md +++ b/BUILD_README.md @@ -498,6 +498,7 @@ adb install "nightly/playtivity-nightly-20250614-143022-a1b2c3d.apk" - 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`) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9124144..03886a3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,13 +22,13 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_21.toString() freeCompilerArgs += listOf("-Xlint:-options") } diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..3f694f5 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true + +# Java version configuration +android.compileOptions.sourceCompatibility=21 +android.compileOptions.targetCompatibility=21 diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 8f50da6..154831c 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,9 +1,11 @@ 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 'spotify_provider.dart'; class AuthProvider extends ChangeNotifier { static const String _bearerTokenKey = 'spotify_bearer_token'; @@ -227,7 +229,7 @@ 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 @@ -322,6 +324,50 @@ class AuthProvider extends ChangeNotifier { print('================================='); } + /// 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 { + print('๐Ÿ”„ 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 + + print('โœ… Authentication state reset completed'); + print('๐Ÿ” Post-reset state:'); + print(' - isAuthenticated: $isAuthenticated'); + print(' - isInitialized: $_isInitialized'); + print(' - bearerToken: ${_bearerToken ?? 'null'}'); + print(' - 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) { + print('โŒ 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...'); @@ -334,6 +380,17 @@ class AuthProvider extends ChangeNotifier { await _clearStoredData(); _buddyService.clearBearerToken(); + // Clear any SpotifyProvider state that might have cached data + try { + final spotifyProvider = Provider.of(context, listen: false); + spotifyProvider.clearData(); + spotifyProvider.clearError(); + spotifyProvider.clearAuthError(); + print('โœ… Cleared SpotifyProvider cached state'); + } catch (e) { + print('โš ๏ธ Could not clear SpotifyProvider state: $e'); + } + // Reset all state variables _isLoading = false; _bearerToken = null; diff --git a/lib/providers/spotify_provider.dart b/lib/providers/spotify_provider.dart index be2525c..b28e80f 100644 --- a/lib/providers/spotify_provider.dart +++ b/lib/providers/spotify_provider.dart @@ -51,6 +51,14 @@ class SpotifyProvider extends ChangeNotifier { print('๐Ÿšจ 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(); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e0e03ee..3d7a704 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -7,9 +7,31 @@ import '../providers/spotify_provider.dart'; import '../services/update_service.dart'; import '../utils/version_utils.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( @@ -18,200 +40,206 @@ class SettingsScreen extends StatelessWidget { 'Settings', style: TextStyle(fontWeight: FontWeight.bold), ), - ), body: SafeArea( + ), + 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, + 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), - ), - ListTile( - leading: const Icon(Icons.email), - title: const Text('Email'), - subtitle: Text(authProvider.currentUser!.email), - ), + 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.public), - title: const Text('Country'), - subtitle: Text(authProvider.currentUser!.country), + 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.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), - ), - ], + ), ), - ), - - const SizedBox(height: 16), + + 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, + 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), - ], + _buildUpdatePreferencesTile(context), + _buildCheckForUpdatesTile(context), + ], + ), ), - ), - 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, + 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, + ), ), ), - ), - 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'), - ); - }, - ), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text('About'), - subtitle: Text('See what your friends are listening to on Spotify'), - ), - ], + 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'), + ); + }, + ), + 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 + } + + // Build widget for update preferences Widget _buildUpdatePreferencesTile(BuildContext context) { - return FutureBuilder( - future: UpdateService.getNightlyBuildPreference(), - builder: (context, snapshot) { - final isNightlyEnabled = snapshot.data ?? false; - - return StatefulBuilder( - builder: (context, setState) { - 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 { - await UpdateService.setNightlyBuildPreference(value); - setState(() {}); // Refresh the widget - - if (value && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Nightly builds enabled. Check for updates to get the latest version.')) - ); - } - }, - ), - ); + // 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); } - ); - }, + }, + ), ); } @@ -224,55 +252,359 @@ class SettingsScreen extends StatelessWidget { 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), + + // 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: [ + 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}\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 + // Handle the update check process with modal dialog Future _checkForUpdates(BuildContext context) async { - // Show loading indicator - final scaffoldMessenger = ScaffoldMessenger.of(context); - - scaffoldMessenger.showSnackBar( - const SnackBar(content: Text('Checking for updates...')) + // Show loading modal + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Checking for updates...'), + ], + ), + ); + }, ); - // Check for updates - final updateResult = await UpdateService.checkForUpdates(forceCheck: true); - - // Hide the loading snackbar - scaffoldMessenger.hideCurrentSnackBar(); - - if (!context.mounted) return; - - // Handle the update result - if (updateResult.hasUpdate && updateResult.updateInfo != null) { - // Show the update dialog - final shouldDownload = await UpdateService.showUpdateDialog( - context, - updateResult.updateInfo!, - ); + try { + // Get current version info + final currentVersion = await UpdateService.getCurrentAppVersion(); + + // Check for updates + final updateResult = await UpdateService.checkForUpdates(forceCheck: true); - if (shouldDownload && context.mounted) { - // Show download dialog and get the downloaded file path - final filePath = await UpdateService.showDownloadDialog( + // 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 (filePath != null && context.mounted) { - // Show installation dialog - await UpdateService.showInstallDialog(context, filePath); + 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 + _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 + final isNightly = await UpdateService.getNightlyBuildPreference(); + 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, + ); } - } else if (updateResult.error != null) { - // Show error - scaffoldMessenger.showSnackBar( - SnackBar(content: Text('Error: ${updateResult.error}')) - ); - } else { - // No updates available - scaffoldMessenger.showSnackBar( - const SnackBar(content: Text('You are already on the latest version!')) - ); + } 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(); + _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'), + ), + ], + ); + }, + ); + } + + // 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) { diff --git a/lib/services/http_interceptor.dart b/lib/services/http_interceptor.dart index 6a8ab20..39006bd 100644 --- a/lib/services/http_interceptor.dart +++ b/lib/services/http_interceptor.dart @@ -55,15 +55,25 @@ class HttpInterceptor { } /// Handle response and check for authentication errors static Future _handleResponse(http.Response response) async { - if ((response.statusCode == 401 || response.statusCode == 403) && _currentContext != null) { - AppLogger.http('HTTP ${response.statusCode} detected - redirecting to login'); + if (response.statusCode == 401 || response.statusCode == 403) { + AppLogger.http('HTTP ${response.statusCode} detected - authentication error'); - // 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..4e016cc 100644 --- a/lib/services/spotify_buddy_service.dart +++ b/lib/services/spotify_buddy_service.dart @@ -222,7 +222,7 @@ class SpotifyBuddyService { } 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 @@ -232,9 +232,20 @@ class SpotifyBuddyService { /// Clears the stored Bearer token and headers void clearBearerToken() { + print('๐Ÿ—‘๏ธ Clearing all SpotifyBuddyService state...'); + + // Clear authentication tokens _directBearerToken = null; _completeCookieString = null; - print('๐Ÿ—‘๏ธ Cleared Bearer token and headers'); + + // Clear all cached data to prevent stale state + clearBuddyListCache(); + _trackDurationCache.clear(); + _artistDetailsCache.clear(); + _cacheModified = false; + _artistCacheModified = false; + + print('โœ… Cleared Bearer token, headers, and all cached data'); } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index f6709c5..6c9d24f 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -11,8 +11,8 @@ import 'app_logger.dart'; class UpdateService { // The base URL for checking updates - static const String _baseUrl = 'https://raw.githubusercontent.com/mliem/playtivity/main'; - static const String _githubReleasesApi = 'https://api.github.com/repos/mliem/playtivity/releases'; + 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 @@ -187,14 +187,23 @@ class UpdateService { final List releasesJson = json.decode(response.body); - // Find the latest release (first one in the list is the latest) - final latestReleaseJson = releasesJson.isNotEmpty ? releasesJson[0] : null; + // 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 releases found', + error: 'No stable releases found', ); } @@ -227,28 +236,44 @@ class UpdateService { // Check for nightly updates static Future _checkNightlyUpdates(AppVersionInfo currentVersion) async { try { - // Fetch the nightly information - final response = await http.get(Uri.parse('$_baseUrl/$_nightlyInfoPath')); + // 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 info: HTTP ${response.statusCode}', + error: 'Failed to fetch nightly releases: HTTP ${response.statusCode}', ); } - final nightlyJson = json.decode(response.body); - final nightlyInfo = UpdateInfo.fromJson(nightlyJson, isNightly: true); + 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 - nightlies have a buildDate field - final hasUpdate = VersionUtils.isNewerNightly( - currentVersion: currentVersion.version, - newVersion: nightlyInfo.version, - newBuildTime: nightlyInfo.buildDate, - ); + // 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'); @@ -268,6 +293,22 @@ class UpdateService { } } + // Helper method to determine if we should update to a nightly build + static bool _shouldUpdateToNightly(String currentVersion, UpdateInfo nightlyInfo) { + // 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 + return VersionUtils.isNewerNightly( + currentVersion: currentVersion, + newVersion: nightlyInfo.version, + newBuildTime: nightlyInfo.buildDate, + ); + } + // Download an update file static Future downloadUpdate(UpdateInfo updateInfo) async { try { @@ -527,7 +568,12 @@ class UpdateInfo { } factory UpdateInfo.fromGithubJson(Map json, {required bool isNightly}) { // JSON structure for GitHub releases API - final version = json['tag_name'] as String; + 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; @@ -542,6 +588,8 @@ class UpdateInfo { 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 { @@ -550,16 +598,81 @@ class UpdateInfo { buildDate = DateTime.now(); } - // Extract build number from release name if possible + // For nightly builds, extract version and build number from the tag name or release body + String version; String buildNumber = ''; - final name = json['name'] as String? ?? version; - final buildMatch = RegExp(r'build\s*(\d+)').firstMatch(name); - if (buildMatch != null) { - buildNumber = buildMatch.group(1) ?? ''; + + 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.startsWith('v') ? version.substring(1) : version, // Remove 'v' prefix if present + version: version, buildNumber: buildNumber, buildDate: buildDate, apkUrl: apkUrl, diff --git a/lib/utils/auth_utils.dart b/lib/utils/auth_utils.dart index 3549f2d..6357845 100644 --- a/lib/utils/auth_utils.dart +++ b/lib/utils/auth_utils.dart @@ -103,7 +103,7 @@ class AuthUtils { return true; } - /// Handles 401/403 errors by immediately navigating to login screen /// Handles 401/403 errors by immediately navigating to login screen + /// 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 { AppLogger.auth('Authentication error detected: ${errorMessage ?? "401/403 error"}'); @@ -111,8 +111,14 @@ class AuthUtils { 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) { @@ -123,7 +129,8 @@ class AuthUtils { duration: const Duration(seconds: 2), ), ); - } } catch (e) { + } + } catch (e) { AppLogger.error('Error handling authentication error', e); // Fallback: try to navigate directly 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/pubspec.yaml b/pubspec.yaml index 7ece78a..9c7aa98 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+1 environment: sdk: ^3.8.1 From c01f852407d209e8edb8b7d657b2da4269039e3c Mon Sep 17 00:00:00 2001 From: mliem2k Date: Fri, 20 Jun 2025 21:57:58 +0800 Subject: [PATCH 06/10] Initial commit Project scaffolding and initial file setup. --- android/app/proguard-rules.pro | 20 + android/app/src/main/AndroidManifest.xml | 11 +- .../com/mliem/playtivity/MainActivity.kt | 162 +++- .../playtivity/widget/ImageCacheService.kt | 24 +- .../widget/LaunchSpotifyProfileCallback.kt | 54 -- .../widget/PlaytivityWidgetProvider.kt | 666 ++++++++------- .../widget/PlaytivityWidgetReceiver.kt | 25 +- .../widget/RefreshWidgetCallback.kt | 26 - .../main/res/layout/widget_activity_item.xml | 81 ++ .../app/src/main/res/layout/widget_layout.xml | 101 +++ android/app/src/main/res/values/colors.xml | 10 +- .../main/res/xml/network_security_config.xml | 17 + .../main/res/xml/playtivity_widget_glance.xml | 4 +- lib/main.dart | 65 +- lib/providers/auth_provider.dart | 162 +++- lib/screens/home_screen.dart | 6 +- lib/screens/login_screen.dart | 290 ++++++- lib/services/background_service.dart | 2 +- lib/services/update_service.dart | 765 ++++++++++++++++-- lib/services/widget_service.dart | 97 ++- lib/utils/update_launcher.dart | 129 ++- lib/utils/version_utils.dart | 152 ++-- lib/widgets/spotify_webview_login.dart | 137 ++-- nightly-apk.js | 48 +- nightly-github-release.js | 51 +- pubspec.yaml | 2 +- 26 files changed, 2352 insertions(+), 755 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/mliem/playtivity/widget/LaunchSpotifyProfileCallback.kt delete mode 100644 android/app/src/main/kotlin/com/mliem/playtivity/widget/RefreshWidgetCallback.kt create mode 100644 android/app/src/main/res/layout/widget_activity_item.xml create mode 100644 android/app/src/main/res/layout/widget_layout.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml 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 e92418b..9942d01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + + + \ 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 5de53b0..d91d875 100644 --- a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt @@ -3,17 +3,16 @@ 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 class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playtivity_widget" private val UPDATE_CHANNEL = "com.mliem.playtivity/update_launcher" @@ -28,6 +27,10 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti updateWidget() result.success("Widget update triggered") } + "cacheImages" -> { + ImageCacheService.startImageCaching(this) + result.success("Image caching started") + } else -> { result.notImplemented() } @@ -45,6 +48,20 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti 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() } @@ -52,58 +69,149 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti } } private fun updateWidget() { try { - // Use direct Glance updateAll approach - CoroutineScope(Dispatchers.Main).launch { - // Reduced delay for faster widget updates - delay(50) - - // Reduced logging for better performance - val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) - val flutterActivitiesCount = flutterPrefs.getString("flutter.activities_count", "0") - - android.util.Log.d("PlaytivityWidget", "Widget update triggered with $flutterActivitiesCount activities") - - // Start image caching service first - ImageCacheService.startImageCaching(this@MainActivity) - - // Update the widget using direct Glance updateAll - PlaytivityAppWidget().updateAll(this@MainActivity) - android.util.Log.d("PlaytivityWidget", "Widget update completed") + // 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") + + android.util.Log.d("PlaytivityWidget", "Widget update triggered - Flutter: $flutterActivitiesCount, HomeWidget: $homeWidgetActivitiesCount activities") + + // 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) } + + android.util.Log.d("PlaytivityWidget", "Widget update completed") } 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") - // Create content URI using FileProvider + 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", e) - result.error("INSTALLATION_ERROR", "Error installing APK: ${e.message}", null) + 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 488225f..2a587e3 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") { @@ -48,11 +47,21 @@ class ImageCacheService : IntentService("ImageCacheService") { 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() + + // Also save to Flutter preferences as backup + val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + flutterPrefs.edit() + .putString("flutter.friend_${i}_cached_image", cachedPath) + .apply() + + android.util.Log.d("ImageCacheService", "Saved cached image path for $friendName: $cachedPath") cachedCount++ + } else { + android.util.Log.w("ImageCacheService", "Failed to cache image for $friendName") } } } @@ -61,10 +70,15 @@ class ImageCacheService : IntentService("ImageCacheService") { } } - // 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) { 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 1604172..5c6ad4b 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,395 +1,373 @@ 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 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 ) { - 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 - - // Reduced logging for better performance - android.util.Log.d("PlaytivityWidget", "Reading widget data: activitiesCount=$activitiesCount, lastUpdate=$lastUpdate") - - // Only log if there are issues or in debug builds - if (activitiesCount == 0) { - android.util.Log.d("PlaytivityWidget", "No activities found") - } else if (activitiesCount > 0) { - android.util.Log.d("PlaytivityWidget", "Processing $activitiesCount activities") - } - - 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 + companion object { + fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + android.util.Log.d("PlaytivityWidget", "Updating widget $appWidgetId") + + // 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", "Widget data: activitiesCount=$activitiesCount") + + 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 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 ) ) } - // 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 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 activity count in header + val currentlyPlayingCount = sortedActivities.count { it.isCurrentlyPlaying } + val countText = if (currentlyPlayingCount > 0) { + "$currentlyPlayingCount live" + } else { + "${sortedActivities.size} total" } + views.setTextViewText(R.id.activity_count, countText) + views.setViewVisibility(R.id.activity_count, View.VISIBLE) + + android.util.Log.d("PlaytivityWidget", "Showing ${sortedActivities.size} activities, $currentlyPlayingCount currently playing") + } 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 + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_container, pendingIntent) + + // Update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.activities_list) + android.util.Log.d("PlaytivityWidget", "Widget update completed") + } + + private fun showEmptyState(views: RemoteViews) { + views.setViewVisibility(R.id.activities_list, View.GONE) + views.setViewVisibility(R.id.empty_state, View.VISIBLE) + views.setViewVisibility(R.id.activity_count, View.GONE) } } +} - @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 PlaytivityWidgetRemoteViewsFactory(this.applicationContext, intent) } +} - @Composable - private fun ActivitiesView(prefs: android.content.SharedPreferences, activitiesCount: Int) { - android.util.Log.d("PlaytivityWidget", "ActivitiesView called with activitiesCount: $activitiesCount") +class PlaytivityWidgetRemoteViewsFactory( + private val context: Context, + 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() - // Only log invalid activities or first few activities to reduce noise - if (!isValid && index < 5) { - android.util.Log.d("PlaytivityWidget", "Activity $index: invalid data") - } else if (isValid && index < 2) { - android.util.Log.d("PlaytivityWidget", "Activity $index: $friendName - $friendTrack") - } - - 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, activity.friendName) - 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 + // Set click intent + val clickIntent = Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + position, + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickFillInIntent(R.id.friend_image, clickIntent) + + 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() { + 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) { + 0 + } + + android.util.Log.d("PlaytivityWidget", "Loading $activitiesCount activities for widget") + activities.clear() + + 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 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()) { + android.util.Log.d("PlaytivityWidget", "Activity $i: $friendName, cached image: $cachedImagePath") + activities.add( + ActivityItem( + friendName = friendName, + trackName = trackName, + artistName = artistName, + cachedImagePath = cachedImagePath, + timestamp = timestamp, + isCurrentlyPlaying = isCurrentlyPlaying, + activityType = activityType ) ) } - } 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) } } + + android.util.Log.d("PlaytivityWidget", "Loaded ${activities.size} activities for ListView") } - - @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, friendName: String) { + android.util.Log.d("PlaytivityWidget", "Loading image for $friendName, path: $cachedImagePath") + + if (cachedImagePath.isNotEmpty()) { + val file = File(cachedImagePath) + android.util.Log.d("PlaytivityWidget", "File exists: ${file.exists()}, readable: ${file.canRead()}, size: ${file.length()}") + + if (file.exists() && file.canRead() && file.length() > 0) { + try { + val bitmap = BitmapFactory.decodeFile(cachedImagePath) + if (bitmap != null && !bitmap.isRecycled) { + android.util.Log.d("PlaytivityWidget", "Successfully loaded bitmap for $friendName (${bitmap.width}x${bitmap.height})") + views.setImageViewBitmap(R.id.friend_image, bitmap) + return + } else { + android.util.Log.w("PlaytivityWidget", "Failed to decode bitmap for $friendName") } + } catch (e: Exception) { + android.util.Log.e("PlaytivityWidget", "Exception loading image for $friendName", e) } + } else { + android.util.Log.w("PlaytivityWidget", "Image file not accessible for $friendName: exists=${file.exists()}, readable=${file.canRead()}, size=${file.length()}") } + } else { + android.util.Log.d("PlaytivityWidget", "No cached image path for $friendName") } + + // Fallback to default icon + android.util.Log.d("PlaytivityWidget", "Using default icon for $friendName") + 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/layout/widget_activity_item.xml b/android/app/src/main/res/layout/widget_activity_item.xml new file mode 100644 index 0000000..c8f8f52 --- /dev/null +++ b/android/app/src/main/res/layout/widget_activity_item.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..4feabfb --- /dev/null +++ b/android/app/src/main/res/layout/widget_layout.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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/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/lib/main.dart b/lib/main.dart index 798d332..8b4053c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,9 +34,12 @@ void main() async { // Check for updates on app startup if enough time has passed Future _checkForUpdatesOnStartup() async { - // Check if we should check for updates - if (await UpdateService.shouldCheckForUpdates()) { - try { + 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(); @@ -45,10 +48,10 @@ Future _checkForUpdatesOnStartup() async { // We'll handle the update notification in the app UI later print('Update available: ${updateResult.updateInfo?.version}'); } - } catch (e) { - // Ignore errors during startup, we don't want to block app launch - print('Error checking for updates on startup: $e'); } + } catch (e) { + // Ignore errors during startup, we don't want to block app launch + print('Error checking for updates on startup: $e'); } } @@ -107,6 +110,8 @@ class AppWrapper extends StatefulWidget { } class _AppWrapperState extends State { + bool _hasVerifiedAuth = false; + @override void dispose() { // Clear the context when the widget is disposed @@ -128,7 +133,22 @@ class _AppWrapperState extends State { print(' - isAuthenticated: ${authProvider.isAuthenticated}'); print(' - currentUser: ${authProvider.currentUser?.displayName ?? 'null'}'); print(' - bearerToken exists: ${authProvider.bearerToken != null}'); - // Show loading screen while authentication is being initialized or in progress + + // Verify authentication state once when the app is fully initialized and not during loading + if (authProvider.isInitialized && !authProvider.isLoading && !_hasVerifiedAuth) { + _hasVerifiedAuth = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + print('๐Ÿ” Performing one-time authentication verification...'); + final isValid = await authProvider.verifyAndRefreshAuth(); + if (!isValid && authProvider.bearerToken != null) { + print('โš ๏ธ Authentication was invalid, clearing state...'); + // Authentication was invalid, the provider already cleared the state + // No need to do anything else as the UI will rebuild automatically + } + }); + } + + // Show loading screen while authentication is being initialized or in progress if (!authProvider.isInitialized || authProvider.isLoading) { print('๐Ÿ“ฑ Showing loading screen...'); return const Scaffold( @@ -173,7 +193,7 @@ class MainNavigationScreen extends StatefulWidget { State createState() => _MainNavigationScreenState(); } -class _MainNavigationScreenState extends State { +class _MainNavigationScreenState extends State with WidgetsBindingObserver { int _currentIndex = 0; final List _screens = [ @@ -186,12 +206,41 @@ class _MainNavigationScreenState extends State { super.initState(); print('๐Ÿ  MainNavigationScreen initialized'); + // Add lifecycle observer + WidgetsBinding.instance.addObserver(this); + // Register background widget updates when user is authenticated WidgetsBinding.instance.addPostFrameCallback((_) { _registerBackgroundUpdates(); }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.resumed) { + print('๐Ÿ“ฑ App resumed, verifying authentication...'); + // Verify authentication when app resumes + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + final authProvider = Provider.of(context, listen: false); + final isValid = await authProvider.verifyAndRefreshAuth(); + if (!isValid) { + print('โš ๏ธ Authentication invalid after app resume'); + // The auth provider will handle clearing state and the UI will rebuild + } + } + }); + } + } + Future _registerBackgroundUpdates() async { try { await BackgroundService.registerWidgetUpdateTask(); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 154831c..786058f 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -136,6 +136,11 @@ class AuthProvider extends ChangeNotifier { print('๐Ÿ”„ 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; _headers = headers; @@ -143,22 +148,44 @@ class AuthProvider extends ChangeNotifier { // Set the Bearer token directly in buddy service _buddyService.setBearerToken(bearerToken, headers); - // Fetch user profile using Bearer token + // Fetch user profile using Bearer token with retry logic print('๐Ÿ”„ Fetching user profile with Bearer token...'); - _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); - if (_currentUser != null) { - print('โœ… Successfully loaded user profile: ${_currentUser!.displayName}'); - } else { - print('โš ๏ธ Failed to load user profile'); + 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) { + print('โœ… Successfully loaded user profile on attempt $attempt: ${userProfile.displayName}'); + break; + } + } catch (e) { + print('โš ๏ธ 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; + print('๐Ÿ”„ 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'); - // 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'); @@ -166,20 +193,69 @@ class AuthProvider extends ChangeNotifier { print(' - isLoading: $_isLoading'); print(' - currentUser: ${_currentUser?.displayName ?? 'null'}'); - // Force UI update with multiple notifications to ensure state propagation + // Notify listeners with a series of notifications to ensure state propagation notifyListeners(); - // Add a short delay and notify again to handle any race conditions - await Future.delayed(const Duration(milliseconds: 50)); + // Add multiple delayed notifications to handle any race conditions + await Future.delayed(const Duration(milliseconds: 100)); notifyListeners(); - // Final notification after a longer delay to ensure all consumers are updated - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 200)); notifyListeners(); + // Final verification with retry logic for long idle scenarios + bool verificationSuccess = false; + for (int verifyAttempt = 1; verifyAttempt <= 3; verifyAttempt++) { + print('๐Ÿ” Final verification attempt $verifyAttempt/3...'); + + if (isAuthenticated) { + verificationSuccess = true; + print('โœ… Authentication verification successful on attempt $verifyAttempt'); + break; + } else { + print('โš ๏ธ Authentication verification failed on attempt $verifyAttempt'); + if (verifyAttempt < 3) { + print('๐Ÿ”„ Waiting 500ms before retry...'); + await Future.delayed(const Duration(milliseconds: 500)); + // Re-notify listeners to ensure state is properly updated + notifyListeners(); + } + } + } + + if (!verificationSuccess) { + // Final check: manually verify the authentication state + final hasToken = _bearerToken != null && _bearerToken!.isNotEmpty; + final hasUser = _currentUser != null; + final isInitComplete = _isInitialized; + + print('๐Ÿ” Manual verification check:'); + print(' - hasToken: $hasToken'); + print(' - hasUser: $hasUser'); + print(' - isInitialized: $isInitComplete'); + + if (hasToken && hasUser && isInitComplete) { + print('โœ… Manual verification passed - authentication is valid'); + verificationSuccess = true; + } else { + throw Exception('Authentication verification failed after completion - Token: $hasToken, User: $hasUser, Init: $isInitComplete'); + } + } + + print('โœ… Authentication flow completed successfully'); + } catch (e) { print('โŒ 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; } @@ -415,4 +491,62 @@ class AuthProvider extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 100)); 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 { + print('๐Ÿ” Verifying and refreshing authentication state...'); + + if (!_isInitialized) { + print('โš ๏ธ 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) { + print('โš ๏ธ Missing basic auth data (token or user)'); + return false; + } + + try { + // Test the current token by making an API call + print('๐Ÿ”„ Testing current authentication with API call...'); + final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); + + if (testUser != null && testUser.id == _currentUser!.id) { + print('โœ… Authentication verified successfully'); + + // Update user info in case anything changed + _currentUser = testUser; + await _saveStoredData(); + + // Ensure state is properly set + _isLoading = false; + _isInitialized = true; + notifyListeners(); + + return true; + } else { + print('โš ๏ธ Authentication test failed - user mismatch or null'); + await _clearStoredData(); + _bearerToken = null; + _headers = null; + _currentUser = null; + notifyListeners(); + return false; + } + } catch (e) { + print('โŒ Authentication verification failed: $e'); + + // Clear invalid auth data + await _clearStoredData(); + _bearerToken = null; + _headers = null; + _currentUser = null; + _isLoading = false; + notifyListeners(); + + return false; + } + } } \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3c94d41..f091dcb 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ import '../widgets/activity_skeleton.dart'; import '../widgets/last_updated_indicator.dart'; import '../utils/auth_utils.dart'; + class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -84,6 +85,8 @@ class _HomeScreenState extends State { } } + + @override Widget build(BuildContext context) { return Scaffold( @@ -101,7 +104,8 @@ class _HomeScreenState extends State { ), ), ), - ), body: SafeArea( + ), + body: SafeArea( child: Consumer2( builder: (context, authProvider, spotifyProvider, child) { return Column( diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 0adb780..30d49f9 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -3,15 +3,29 @@ 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'; 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 +34,7 @@ class LoginScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Spacer(), - // Playtivity Logo + // Playtivity Logo Container( width: 120, height: 120, @@ -58,7 +72,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 +91,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,7 +116,8 @@ 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), @@ -113,7 +131,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 +143,9 @@ class LoginScreen extends StatelessWidget { ), ), - const Spacer(), // Footer + const Spacer(), + + // Footer Text( 'Made with โค๏ธ for music lovers', textAlign: TextAlign.center, @@ -145,17 +167,30 @@ 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'); try { + // Process authentication without immediate navigation await authProvider.handleAuthComplete(bearerToken, headers); print('โœ… Authentication handling completed successfully'); - // Ensure we're back on the login screen before popping - if (context.mounted && Navigator.canPop(context)) { - Navigator.of(context).pop(true); + // Add a small delay to ensure state is fully updated + await Future.delayed(const Duration(milliseconds: 200)); + + // Verify authentication was successful before proceeding + if (authProvider.isAuthenticated) { + print('โœ… 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 { + print('โŒ Authentication failed - auth provider not authenticated'); + throw Exception('Authentication verification failed'); } } catch (e) { print('โŒ Error in auth completion: $e'); @@ -182,29 +217,244 @@ 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) { + print('โœ… Login successful, navigating to home screen...'); + if (context.mounted) { + // Use pushNamedAndRemoveUntil to ensure clean navigation stack + Navigator.of(context).pushNamedAndRemoveUntil( + '/', + (route) => false, + ); + } + } else { + print('โŒ 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 + print('โ„น๏ธ Login was cancelled or failed'); authProvider.cancelLogin(); + } + } catch (e) { + print('โŒ 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: [ + CircularProgressIndicator(), + SizedBox(height: 16), + 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 + 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 + 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withOpacity(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; + } } \ No newline at end of file diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 1ba9700..4403ed4 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -14,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 ); } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 6c9d24f..a00e9ce 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -1,5 +1,6 @@ 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'; @@ -89,6 +90,28 @@ class UpdateService { 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 @@ -118,10 +141,15 @@ class UpdateService { // 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 @@ -137,13 +165,31 @@ class UpdateService { await _markUpdateChecked(); // Determine if there's an update available - // Prefer nightly if user opted in and it's newer than release - if (useNightly && nightlyCheck != null && nightlyCheck.hasUpdate) { - AppLogger.info('Nightly update available: ${nightlyCheck.updateInfo?.version}'); - return nightlyCheck; + // 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, + ); } - // Otherwise return the release update if available + // For stable users, only offer release updates if (releaseCheck.hasUpdate) { AppLogger.info('Release update available: ${releaseCheck.updateInfo?.version}'); return releaseCheck; @@ -216,7 +262,7 @@ class UpdateService { newVersion: releaseInfo.version, ); - AppLogger.info('Release check: Latest=${releaseInfo.version}, hasUpdate=$hasUpdate'); + AppLogger.info('Release check: Current=${currentVersion.version}, Latest=${releaseInfo.version}, hasUpdate=$hasUpdate'); return UpdateCheckResult( hasUpdate: hasUpdate, @@ -293,8 +339,55 @@ class UpdateService { } } + // 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'); @@ -302,15 +395,21 @@ class UpdateService { } // If both are nightly, compare build dates - return VersionUtils.isNewerNightly( + 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 - static Future downloadUpdate(UpdateInfo updateInfo) async { + // 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})'); @@ -321,8 +420,10 @@ class UpdateService { // Create the file final file = File(filePath); - // Download the file - final response = await http.get(Uri.parse(updateInfo.apkUrl)); + // 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( @@ -332,8 +433,47 @@ class UpdateService { ); } - // Write the file - await file.writeAsBytes(response.bodyBytes); + // 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'); @@ -397,10 +537,29 @@ class UpdateService { ) ?? 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); @@ -408,59 +567,6 @@ class UpdateService { } } - // Show a download progress dialog - static Future showDownloadDialog(BuildContext context, UpdateInfo updateInfo) async { - return await showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return FutureBuilder( - future: downloadUpdate(updateInfo), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return AlertDialog( - title: const Text('Downloading Update'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: const [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Please wait while we download the update...'), - ], - ), - ); - } else { - if (snapshot.hasError || (snapshot.data != null && !snapshot.data!.success)) { - return AlertDialog( - title: const Text('Download Failed'), - content: Text('Failed to download the update: ${snapshot.error ?? snapshot.data?.error ?? 'Unknown error'}'), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(null); - }, - child: const Text('OK'), - ), - ], - ); - } else { - // Auto-close the dialog and return the path - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pop(snapshot.data!.filePath); - }); - - return const AlertDialog( - title: Text('Download Complete'), - content: Text('Update downloaded successfully!'), - ); - } - } - }, - ); - }, - ); - } - // Show an installation progress/instruction dialog static Future showInstallDialog(BuildContext context, String filePath) async { return await showDialog( @@ -469,7 +575,33 @@ class UpdateService { builder: (BuildContext context) { return AlertDialog( title: const Text('Install Update'), - content: const Text('The update has been downloaded and is ready to install. The app will close during installation.'), + 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withOpacity(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: () { @@ -479,14 +611,225 @@ class UpdateService { ), ElevatedButton( onPressed: () async { - final success = await installUpdate(filePath); - if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to install update. Please try again.')), - ); + // 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withOpacity(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) { + 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withOpacity(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; } - if (context.mounted) { - Navigator.of(context).pop(success); + + // Show loading state + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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(); + + 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 + 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'), + ), + ], + ), + ); + + Navigator.of(context).pop(false); + } } }, child: const Text('Install Now'), @@ -606,12 +949,12 @@ class UpdateInfo { 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); + 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); + versionRegex = RegExp(r'Version[:\s]*`?([^`\s\n]+)`?', caseSensitive: false); versionMatch = versionRegex.firstMatch(body); } @@ -637,7 +980,7 @@ class UpdateInfo { 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); + RegExp versionRegex = RegExp(r'Version[:\s]*`?([^`\s\n]+)`?', caseSensitive: false); RegExpMatch? versionMatch = versionRegex.firstMatch(body); if (versionMatch != null) { @@ -651,7 +994,7 @@ class UpdateInfo { } // Look for build number in the release body - RegExp buildRegex = RegExp(r'Build Number[:\s]*(\d+)', caseSensitive: false); + RegExp buildRegex = RegExp(r'Build Number[:\s]*`?(\d+)`?', caseSensitive: false); RegExpMatch? buildMatch = buildRegex.firstMatch(body); if (buildMatch != null) { @@ -721,3 +1064,269 @@ class UpdateDownloadResult { 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({Key? key, required this.updateInfo}) : super(key: key); + + @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) { + if (bytesPerSecond < 1024) return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + if (bytesPerSecond < 1024 * 1024) return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + if (bytesPerSecond < 1024 * 1024 * 1024) return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + return '${(bytesPerSecond / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB/s'; + } + + 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 CircularProgressIndicator(), + const SizedBox(height: 16), + 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 d3f6c58..56a3841 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -15,6 +15,7 @@ class WidgetService { 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 @@ -45,7 +46,10 @@ class WidgetService { 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); + await prefs.setString(key, value); // Also save without flutter prefix + // Only log key operations to reduce noise if (key == 'activities_count' || key == 'last_update') { print('๐Ÿ“Š Widget data saved: flutter.$key = $value'); @@ -56,6 +60,7 @@ class WidgetService { } // Update widget with friends' activities only + @pragma('vm:entry-point') static Future updateWidget({ User? currentUser, List? friendsActivities, @@ -171,13 +176,27 @@ class WidgetService { print('๐Ÿ“Š Widget: About to call updateWidget()'); print('๐Ÿ“Š Widget: Using androidName: $_androidWidgetName'); - // 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 + await HomeWidget.updateWidget( + qualifiedAndroidName: _androidWidgetName, + iOSName: _iOSWidgetName, + ); + print('๐Ÿ“ฑ Widget updated successfully'); + + // Force image caching after a short delay + Future.delayed(const Duration(milliseconds: 500), () async { + try { + await _channel.invokeMethod('cacheImages'); + print('๐Ÿ“ธ Image caching triggered'); + } catch (e) { + print('โŒ Image caching failed: $e'); + } + }); + + } catch (e) { + print('โŒ Widget update failed: $e'); + // Data is still saved, widget may update on next system refresh print('๐Ÿ“ฑ Data saved to SharedPreferences - widget will update on next refresh'); } } catch (e) { @@ -220,7 +239,7 @@ class WidgetService { } await HomeWidget.updateWidget( - androidName: _androidWidgetName, + qualifiedAndroidName: _androidWidgetName, iOSName: _iOSWidgetName, ); } catch (e) { @@ -266,4 +285,68 @@ class WidgetService { print('โŒ Error in widget debug: $e'); } } + + // Comprehensive debug for release builds + static Future> debugReleaseWidget() async { + final debugInfo = {}; + + try { + print('๐Ÿž === 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; + + print('๐Ÿž Total keys in SharedPreferences: ${allKeys.length}'); + print('๐Ÿž 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; + + print('๐Ÿž Flutter activities count: $flutterActivitiesCount'); + print('๐Ÿž 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; + print('๐Ÿž Method channel works: $result'); + } catch (e) { + debugInfo['method_channel_error'] = e.toString(); + print('๐Ÿž 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; + print('๐Ÿž HomeWidget integration works'); + } catch (e) { + debugInfo['home_widget_error'] = e.toString(); + print('๐Ÿž HomeWidget integration failed: $e'); + } + + print('๐Ÿž === END RELEASE DEBUG ==='); + + } catch (e) { + debugInfo['debug_error'] = e.toString(); + print('โŒ Debug error: $e'); + } + + return debugInfo; + } } \ No newline at end of file diff --git a/lib/utils/update_launcher.dart b/lib/utils/update_launcher.dart index 793ee51..157f8d8 100644 --- a/lib/utils/update_launcher.dart +++ b/lib/utils/update_launcher.dart @@ -9,6 +9,37 @@ 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. @@ -27,49 +58,85 @@ class UpdateLauncher { return false; } - // Log information - AppLogger.info('Installing APK from: $filePath'); + // 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; - // For Android, use platform-specific code to install the APK + // Approach 1: Try using the platform channel first try { - // Try using the platform channel first + 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) { - // If platform channel fails, fallback to opening the file with Intent - AppLogger.warning('Platform channel failed, falling back to file intent: ${e.message}'); - - // Convert file path to URI and launch it + 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); - return await _launchFileIntent(uri); + 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'); } - } catch (e) { - AppLogger.error('Error installing APK', e); - return false; - } - } - - /// Launches a URI to open a file with the appropriate intent. - /// - /// Used for APK installation when the platform channel is not available. - static Future _launchFileIntent(Uri uri) async { - try { - // For APKs, we need to add the mime type and ensure proper intent flags - final canLaunch = await canLaunchUrl(uri); - if (!canLaunch) { - AppLogger.error('Cannot launch file: ${uri.toString()}'); - return false; + // 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); } - // Launch with specific parameters for APK files - return await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); + // All approaches failed + AppLogger.error('All installation approaches failed. Last error: $lastException'); + return false; + } catch (e) { - AppLogger.error('Error launching file intent', e); + AppLogger.error('Error installing APK', e); return false; } } + + } diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart index 7f96ed0..f39d7b0 100644 --- a/lib/utils/version_utils.dart +++ b/lib/utils/version_utils.dart @@ -3,6 +3,19 @@ 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". @@ -11,56 +24,79 @@ class VersionUtils { 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 release, prefer the release - return true; + } 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 currentClean = currentVersion.split('+')[0]; - final newClean = newVersion.split('+')[0]; + final v1Clean = version1.split('+')[0]; + final v2Clean = version2.split('+')[0]; // Split versions into parts (split by periods or dashes) - final currentParts = currentClean.split('-'); - final newParts = newClean.split('-'); + final v1Parts = v1Clean.split('-'); + final v2Parts = v2Clean.split('-'); // Compare base version parts (x.y.z) - final currentBaseParts = currentParts[0].split('.'); - final newBaseParts = newParts[0].split('.'); + final v1BaseParts = v1Parts[0].split('.'); + final v2BaseParts = v2Parts[0].split('.'); // Normalize to have at least 3 parts (x.y.z) - while (currentBaseParts.length < 3) currentBaseParts.add('0'); - while (newBaseParts.length < 3) newBaseParts.add('0'); + 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 current = int.tryParse(currentBaseParts[i]) ?? 0; - final next = int.tryParse(newBaseParts[i]) ?? 0; + final v1Part = int.tryParse(v1BaseParts[i]) ?? 0; + final v2Part = int.tryParse(v2BaseParts[i]) ?? 0; - if (next > current) return true; - if (next < current) return false; + if (v2Part > v1Part) return 1; // v2 is newer + if (v2Part < v1Part) return -1; // v1 is newer } - // If base versions are equal, check pre-release identifiers (e.g., -beta, -rc) + // 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 (currentParts.length == 1 && newParts.length > 1) return false; - if (currentParts.length > 1 && newParts.length == 1) return true; + 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 - if (currentParts.length > 1 && newParts.length > 1) { - return newParts[1].compareTo(currentParts[1]) > 0; + // 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 false; + return 0; } /// Specifically compares nightly build versions based on build date/time @@ -71,48 +107,66 @@ class VersionUtils { required String newVersion, required DateTime newBuildTime, }) { - // Extract the build timestamp from the version string if possible - // Nightly versions typically have format: x.y.z-nightly-YYYYMMDD-HHMMSS + debugPrint('Comparing nightly versions:'); + debugPrint(' Current: $currentVersion'); + debugPrint(' New: $newVersion'); + debugPrint(' New build time: $newBuildTime'); + + // First, check if the versions are exactly the same (ignoring build metadata after +) + final currentClean = currentVersion.split('+')[0]; + final newClean = newVersion.split('+')[0]; + + if (currentClean == newClean) { + debugPrint(' Versions are identical (ignoring build metadata), no update needed'); + return false; + } + + // Extract the build timestamp from the current version string if possible DateTime? currentBuildTime; try { if (currentVersion.contains('nightly')) { - final parts = currentVersion.split('-nightly-'); - if (parts.length > 1) { - final dateTimePart = parts[1].split('+')[0]; + // Handle complex nightly version strings that may have multiple nightly parts + final nightlyRegex = RegExp(r'nightly-(\d{8})-(\d{6})'); + final matches = nightlyRegex.allMatches(currentVersion); + + if (matches.isNotEmpty) { + // Use the last (most recent) nightly timestamp in the version string + final lastMatch = matches.last; + final datePart = lastMatch.group(1)!; // YYYYMMDD + final timePart = lastMatch.group(2)!; // HHMMSS - // Try to parse the date/time part - if (dateTimePart.length >= 8) { - // Format is YYYYMMDD or YYYYMMDD-HHMMSS - final datePart = dateTimePart.substring(0, 8); - final year = int.parse(datePart.substring(0, 4)); - final month = int.parse(datePart.substring(4, 6)); - final day = int.parse(datePart.substring(6, 8)); - - int hour = 0, minute = 0, second = 0; - if (dateTimePart.length >= 15 && dateTimePart.contains('-')) { - final timePart = dateTimePart.substring(9, 15); - hour = int.parse(timePart.substring(0, 2)); - minute = int.parse(timePart.substring(2, 4)); - second = int.parse(timePart.substring(4, 6)); - } - - currentBuildTime = DateTime(year, month, day, hour, minute, second); - } + 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'); } } } catch (e) { debugPrint('Error parsing current nightly version: $e'); - // Fall back to normal version comparison } - // If we can't determine build time from version string, compare normal versions + // If we can't determine build time from version string or they're too close, + // use a more lenient comparison if (currentBuildTime == null) { + debugPrint(' Could not parse current build time, using version string comparison'); return isNewerVersion(currentVersion: currentVersion, newVersion: newVersion); } - // Compare build times - return newBuildTime.isAfter(currentBuildTime); + // Compare build times with a threshold to avoid updates for very recent builds + final timeDifference = newBuildTime.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. diff --git a/lib/widgets/spotify_webview_login.dart b/lib/widgets/spotify_webview_login.dart index e2b686a..9c0cbb1 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.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({ @@ -197,18 +198,18 @@ class _SpotifyWebViewLoginState extends State { if (url == null) return; String urlString = url.toString(); - print('๐ŸŒ Page loaded: $urlString'); + AppLogger.auth('Page loaded: $urlString'); // Update current URL and r logic 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; }); @@ -256,7 +257,7 @@ class _SpotifyWebViewLoginState extends State { } // Only log other console messages for debugging - print('๐ŸŒ WebView Console [${consoleMessage.messageLevel}]: ${consoleMessage.message}'); + AppLogger.debug('WebView Console [${consoleMessage.messageLevel}]: ${consoleMessage.message}'); }, ), @@ -333,23 +334,23 @@ 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 @@ -370,13 +371,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'; }); @@ -385,7 +386,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() { @@ -526,7 +527,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(';'); @@ -620,18 +621,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) { @@ -647,16 +648,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'); @@ -700,16 +701,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) { @@ -717,12 +718,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; @@ -730,55 +731,69 @@ class _SpotifyWebViewLoginState extends State { } if (bearerToken != null && bearerToken.isNotEmpty) { - print('๐ŸŽฏ Token polling found complete result!'); + AppLogger.auth('Token polling found complete result!'); timer.cancel(); - 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 + // Complete authentication but don't navigate immediately 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...'); + AppLogger.debug('Final headers: ${_extractedHeaders.keys.join(', ')}'); + AppLogger.debug('Cookie header length: ${_extractedHeaders['Cookie']?.length ?? 0}'); // Add debug logging for the callback try { - widget.onAuthComplete(bearerToken, _extractedHeaders); - print('โœ… onAuthComplete callback executed successfully'); + // Call the callback and wait for it to complete + await widget.onAuthComplete(bearerToken, _extractedHeaders); + AppLogger.auth('onAuthComplete callback executed successfully'); + + // Add a longer delay to ensure all auth processing is complete + await Future.delayed(const Duration(milliseconds: 500)); + + // Only close the WebView after ensuring the callback completed successfully + if (mounted && Navigator.canPop(context)) { + AppLogger.auth('Closing WebView after successful authentication'); + // Don't return true/false, let the parent handle navigation + 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 there's an error, stay on the WebView and let the user retry + if (mounted) { + String errorMessage = e.toString(); + // Make authentication timeout errors more user-friendly + 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'; + }); + } } } } 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...'); }); } @@ -800,20 +815,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/nightly-apk.js b/nightly-apk.js index 33f59c1..ce8260c 100644 --- a/nightly-apk.js +++ b/nightly-apk.js @@ -66,6 +66,19 @@ class NightlyBuilder { }; } + 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'); @@ -87,15 +100,26 @@ class NightlyBuilder { throw new Error('Version line not found in pubspec.yaml'); } - this.log(`Base version: ${currentVersion.fullVersion}`); + 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 + // Create nightly version with date and time (local timezone) const now = new Date(); - const dateStr = now.toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD - const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS + 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 - const nightlyVersionName = `${currentVersion.versionName}-nightly-${dateStr}-${timeStr}`; + // 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}`; @@ -118,7 +142,8 @@ class NightlyBuilder { }, backup: backupContent, dateStr, - timeStr + timeStr, + baseVersion: baseVersionName }; } @@ -341,13 +366,18 @@ class NightlyBuilder { buildType: 'nightly', buildDate: new Date().toISOString(), version: versionInfo.nightly, - baseVersion: versionInfo.original, + 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() + }, + environment: this.getBuildEnvironmentInfo() }; fs.writeFileSync(infoPath, JSON.stringify(nightlyInfo, null, 2)); diff --git a/nightly-github-release.js b/nightly-github-release.js index d7ba9fd..ec704d7 100644 --- a/nightly-github-release.js +++ b/nightly-github-release.js @@ -196,15 +196,19 @@ This build contains the latest development code and may be unstable. Use at your ## 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**: ${buildInfo.version?.fullVersion || buildInfo.version?.versionName || 'Unknown'} -- **Base Version**: ${buildInfo.baseVersion?.fullVersion || buildInfo.baseVersion?.versionName || 'Unknown'} +- **Version**: \`${nightlyVersion}\` +- **Base Version**: \`${baseVersion}\` ## Git Information -- **Branch**: ${buildInfo.git?.branch || 'unknown'} -- **Commit**: ${buildInfo.git?.commit || 'unknown'} -- **Commit Message**: ${buildInfo.git?.commitMessage || 'Unknown'} +- **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'} @@ -224,23 +228,54 @@ This build contains the latest development code and may be unstable. Use at your ## 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 commits up to ${buildInfo?.git?.commit?.substring(0, 7) || 'unknown'}. +This nightly build includes all development changes up to commit **${buildInfo?.git?.commit?.substring(0, 7) || 'unknown'}**. -**Note**: This is an automated nightly release. For stable releases, please see the main releases page. +For detailed changes, check the [commit history](https://github.com/${this.githubRepo}/commits/${buildInfo?.git?.branch || 'main'}). --- -*Built automatically from the latest development code* +*๐Ÿค– 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'); diff --git a/pubspec.yaml b/pubspec.yaml index 9c7aa98..5370c83 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.2+1 +version: 0.0.2-nightly-20250619-032135-nightly-20250620-050418+1750367058 environment: sdk: ^3.8.1 From f80a91b48e632fa75957bcd20db242cc2294d486 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Mon, 23 Jun 2025 19:53:36 +0800 Subject: [PATCH 07/10] Improve authentication verification and error handling Refines authentication logic to only verify on app resume, adds robust validation of restored tokens, and simplifies state notification to avoid race conditions. Also enhances error handling and logging in the authentication provider. Updates package.json to repurpose the project for GitHub release deletion automation. --- lib/main.dart | 44 ++++++----- lib/providers/auth_provider.dart | 125 +++++++++++++++++++------------ 2 files changed, 102 insertions(+), 67 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 8b4053c..ca1f024 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -110,7 +110,6 @@ class AppWrapper extends StatefulWidget { } class _AppWrapperState extends State { - bool _hasVerifiedAuth = false; @override void dispose() { @@ -134,19 +133,9 @@ class _AppWrapperState extends State { print(' - currentUser: ${authProvider.currentUser?.displayName ?? 'null'}'); print(' - bearerToken exists: ${authProvider.bearerToken != null}'); - // Verify authentication state once when the app is fully initialized and not during loading - if (authProvider.isInitialized && !authProvider.isLoading && !_hasVerifiedAuth) { - _hasVerifiedAuth = true; - WidgetsBinding.instance.addPostFrameCallback((_) async { - print('๐Ÿ” Performing one-time authentication verification...'); - final isValid = await authProvider.verifyAndRefreshAuth(); - if (!isValid && authProvider.bearerToken != null) { - print('โš ๏ธ Authentication was invalid, clearing state...'); - // Authentication was invalid, the provider already cleared the state - // No need to do anything else as the UI will rebuild automatically - } - }); - } + // 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) { @@ -227,14 +216,31 @@ class _MainNavigationScreenState extends State with Widget if (state == AppLifecycleState.resumed) { print('๐Ÿ“ฑ App resumed, verifying authentication...'); - // Verify authentication when app resumes + // 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); - final isValid = await authProvider.verifyAndRefreshAuth(); - if (!isValid) { - print('โš ๏ธ Authentication invalid after app resume'); - // The auth provider will handle clearing state and the UI will rebuild + + // Skip verification if user is actively logging in or if auth provider is not initialized + if (authProvider.isLoading || !authProvider.isInitialized) { + print('โš ๏ธ Skipping auth verification - login in progress or not initialized'); + return; + } + + // Only verify if we think we should be authenticated + if (authProvider.isAuthenticated) { + print('๐Ÿ”„ Verifying existing authentication...'); + final isValid = await authProvider.verifyAndRefreshAuth(); + if (!isValid) { + print('โš ๏ธ 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 { + print('โœ… Authentication verified successfully after app resume'); + } + } else { + print('โ„น๏ธ No authentication to verify - user not logged in'); } } }); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 786058f..58bd11b 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -32,10 +32,19 @@ class AuthProvider extends ChangeNotifier { } // Check for Bearer token authentication - final tokenAuth = _bearerToken != null && _currentUser != null; + final hasToken = _bearerToken != null && _bearerToken!.isNotEmpty; + final hasUser = _currentUser != null; + + // Also verify the buddy service has the token + final buddyServiceToken = _buddyService.getBearerToken(); + final buddyServiceHasToken = buddyServiceToken != null && buddyServiceToken.isNotEmpty; + + final tokenAuth = hasToken && hasUser && buddyServiceHasToken; print('๐Ÿ” isAuthenticated evaluation:'); - print(' - Token valid: $tokenAuth (token: ${_bearerToken != null}, user: ${_currentUser != null})'); + print(' - Local token: $hasToken'); + print(' - Local user: $hasUser'); + print(' - Buddy service token: $buddyServiceHasToken'); print(' - User: ${_currentUser?.displayName ?? 'none'}'); print(' - Final result: $tokenAuth'); @@ -69,12 +78,48 @@ class AuthProvider extends ChangeNotifier { final userMap = json.decode(savedUserJson); _currentUser = User.fromJson(userMap); print('โœ… 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 { + print('๐Ÿ”„ Validating restored authentication...'); + final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); + if (testUser != null && testUser.id == _currentUser!.id) { + print('โœ… Restored authentication is valid'); + } else { + print('โš ๏ธ Restored authentication validation failed - clearing stored data'); + await _clearStoredData(); + _bearerToken = null; + _headers = null; + _currentUser = null; + } + } catch (e) { + print('โš ๏ธ 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}'); + try { + _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); + if (_currentUser != null) { + print('โœ… Successfully loaded user profile: ${_currentUser!.displayName}'); + // Save the user profile for next time + await _prefs.setString(_userKey, json.encode(_currentUser!.toJson())); + } else { + print('โš ๏ธ Failed to load user profile - clearing stored data'); + await _clearStoredData(); + _bearerToken = null; + _headers = null; + } + } catch (e) { + print('โš ๏ธ Failed to load user profile: $e - clearing stored data'); + await _clearStoredData(); + _bearerToken = null; + _headers = null; } } } catch (e) { @@ -148,6 +193,15 @@ class AuthProvider extends ChangeNotifier { // Set the Bearer token directly in buddy service _buddyService.setBearerToken(bearerToken, headers); + // Verify the buddy service has the token properly set + print('๐Ÿ” 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('โœ… Buddy service token verified: ${buddyServiceToken.substring(0, 20)}...'); + } + // Fetch user profile using Bearer token with retry logic print('๐Ÿ”„ Fetching user profile with Bearer token...'); User? userProfile; @@ -193,53 +247,28 @@ class AuthProvider extends ChangeNotifier { print(' - isLoading: $_isLoading'); print(' - currentUser: ${_currentUser?.displayName ?? 'null'}'); - // Notify listeners with a series of notifications to ensure state propagation + // Notify listeners once with the final state notifyListeners(); - // Add multiple delayed notifications to handle any race conditions + // 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(); - - await Future.delayed(const Duration(milliseconds: 200)); - notifyListeners(); - // Final verification with retry logic for long idle scenarios - bool verificationSuccess = false; - for (int verifyAttempt = 1; verifyAttempt <= 3; verifyAttempt++) { - print('๐Ÿ” Final verification attempt $verifyAttempt/3...'); - - if (isAuthenticated) { - verificationSuccess = true; - print('โœ… Authentication verification successful on attempt $verifyAttempt'); - break; - } else { - print('โš ๏ธ Authentication verification failed on attempt $verifyAttempt'); - if (verifyAttempt < 3) { - print('๐Ÿ”„ Waiting 500ms before retry...'); - await Future.delayed(const Duration(milliseconds: 500)); - // Re-notify listeners to ensure state is properly updated - notifyListeners(); - } - } - } - - if (!verificationSuccess) { - // Final check: manually verify the authentication state - final hasToken = _bearerToken != null && _bearerToken!.isNotEmpty; - final hasUser = _currentUser != null; - final isInitComplete = _isInitialized; - - print('๐Ÿ” Manual verification check:'); - print(' - hasToken: $hasToken'); - print(' - hasUser: $hasUser'); - print(' - isInitialized: $isInitComplete'); - - if (hasToken && hasUser && isInitComplete) { - print('โœ… Manual verification passed - authentication is valid'); - verificationSuccess = true; - } else { - throw Exception('Authentication verification failed after completion - Token: $hasToken, User: $hasUser, Init: $isInitComplete'); - } + // 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; + + print('๐Ÿ” Final verification check:'); + print(' - hasToken: $hasToken'); + print(' - hasUser: $hasUser'); + print(' - isInitialized: $isInitComplete'); + print(' - buddyServiceHasToken: $buddyServiceHasToken'); + + if (!hasToken || !hasUser || !isInitComplete || !buddyServiceHasToken) { + throw Exception('Authentication verification failed after completion - Token: $hasToken, User: $hasUser, Init: $isInitComplete, BuddyService: $buddyServiceHasToken'); } print('โœ… Authentication flow completed successfully'); From 24289ef118ee31b179423b6c0f278a98a536e002 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Tue, 24 Jun 2025 14:56:25 +0800 Subject: [PATCH 08/10] Make widget activity items clickable to open Spotify profiles Enhanced both Android and iOS widgets so that tapping an activity item opens the friend's Spotify profile, using the Spotify app if available or falling back to the web. Added userId support throughout widget data, implemented unified profile launcher in Flutter, and updated UI elements and click handlers for a consistent, robust cross-platform experience. --- WIDGET_CLICKABLE_IMPROVEMENTS.md | 112 ++++++++++++++++++ .../com/mliem/playtivity/MainActivity.kt | 88 +++++++++++++- .../widget/PlaytivityWidgetProvider.kt | 96 +++++++++++---- .../app/src/main/res/drawable/ic_refresh.xml | 3 +- .../main/res/layout/widget_activity_item.xml | 5 +- .../app/src/main/res/layout/widget_layout.xml | 16 ++- ios/PlaytivityWidget/PlaytivityWidget.swift | 100 +++++++++++----- ios/PlaytivityWidget/WidgetDataProvider.swift | 5 +- lib/screens/login_screen.dart | 4 +- lib/screens/settings_screen.dart | 4 +- lib/services/update_service.dart | 13 +- lib/services/widget_service.dart | 5 +- lib/utils/friend_profile_launcher.dart | 68 +++++++++++ lib/widgets/activity_card.dart | 11 +- 14 files changed, 447 insertions(+), 83 deletions(-) create mode 100644 WIDGET_CLICKABLE_IMPROVEMENTS.md create mode 100644 lib/utils/friend_profile_launcher.dart 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/src/main/kotlin/com/mliem/playtivity/MainActivity.kt b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt index d91d875..72967fc 100644 --- a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt @@ -13,6 +13,7 @@ import io.flutter.plugin.common.MethodChannel import com.mliem.playtivity.widget.PlaytivityWidgetReceiver import com.mliem.playtivity.widget.PlaytivityWidgetProvider import com.mliem.playtivity.widget.ImageCacheService +import android.app.PendingIntent class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playtivity_widget" private val UPDATE_CHANNEL = "com.mliem.playtivity/update_launcher" @@ -31,6 +32,16 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti 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() } @@ -67,7 +78,82 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti } } } - } private fun updateWidget() { + } + + 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 { // Check both SharedPreferences sources val flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) 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 5c6ad4b..02125cc 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 @@ -23,7 +23,8 @@ data class ActivityItem( val cachedImagePath: String, val timestamp: Long, val isCurrentlyPlaying: Boolean, - val activityType: String + val activityType: String, + val userId: String = "" ) { fun getTimeAgoText(): String { if (isCurrentlyPlaying) { @@ -102,6 +103,10 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { ?: 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", "") ?: "" + + android.util.Log.d("PlaytivityWidget", "Loading activity $i - Name: $friendName, Track: $trackName, UserId: $userId") val timestamp = try { timestampString.toLongOrNull() ?: System.currentTimeMillis() @@ -124,7 +129,8 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { cachedImagePath = cachedImagePath, timestamp = timestamp, isCurrentlyPlaying = isCurrentlyPlaying, - activityType = activityType + activityType = activityType, + userId = userId ) ) } @@ -145,6 +151,17 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { 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) + android.util.Log.d("PlaytivityWidget", "Set pending intent template for ListView") + // Set empty view views.setEmptyView(R.id.activities_list, R.id.empty_state) @@ -152,15 +169,11 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { views.setViewVisibility(R.id.activities_list, View.VISIBLE) views.setViewVisibility(R.id.empty_state, View.GONE) - // Update activity count in header + // Update currently playing count val currentlyPlayingCount = sortedActivities.count { it.isCurrentlyPlaying } - val countText = if (currentlyPlayingCount > 0) { - "$currentlyPlayingCount live" - } else { - "${sortedActivities.size} total" + if (currentlyPlayingCount > 0) { + android.util.Log.d("PlaytivityWidget", "Showing $currentlyPlayingCount live activities") } - views.setTextViewText(R.id.activity_count, countText) - views.setViewVisibility(R.id.activity_count, View.VISIBLE) android.util.Log.d("PlaytivityWidget", "Showing ${sortedActivities.size} activities, $currentlyPlayingCount currently playing") } else { @@ -170,13 +183,25 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { showEmptyState(views) } - // Set click intent to open main app - val intent = Intent(context, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, + // 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_container, pendingIntent) + views.setOnClickPendingIntent(R.id.widget_title, mainPendingIntent) + android.util.Log.d("PlaytivityWidget", "Set main app click on header title") + + // 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) + android.util.Log.d("PlaytivityWidget", "Set refresh button click handler") // Update the widget appWidgetManager.updateAppWidget(appWidgetId, views) @@ -187,7 +212,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { private fun showEmptyState(views: RemoteViews) { views.setViewVisibility(R.id.activities_list, View.GONE) views.setViewVisibility(R.id.empty_state, View.VISIBLE) - views.setViewVisibility(R.id.activity_count, View.GONE) } } } @@ -248,15 +272,27 @@ class PlaytivityWidgetRemoteViewsFactory( // Load friend image loadFriendImage(views, activity.cachedImagePath, activity.friendName) - // Set click intent - val clickIntent = Intent(context, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - context, - position, - clickIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickFillInIntent(R.id.friend_image, clickIntent) + // Set click intent to open friend profile only if we have a valid user ID + if (activity.userId.isNotEmpty()) { + val clickIntent = Intent() + clickIntent.action = "OPEN_FRIEND_PROFILE" + clickIntent.putExtra("friendUserId", activity.userId) + clickIntent.putExtra("friendName", activity.friendName) + android.util.Log.d("PlaytivityWidget", "Setting click intent for ${activity.friendName} with userId: ${activity.userId}") + + // Make multiple elements clickable for better UX + views.setOnClickFillInIntent(R.id.friend_image, clickIntent) + views.setOnClickFillInIntent(R.id.track_name, clickIntent) + views.setOnClickFillInIntent(R.id.friend_artist, clickIntent) + views.setOnClickFillInIntent(R.id.timestamp, clickIntent) + + // Make the entire item clickable + views.setOnClickFillInIntent(R.id.widget_activity_item, clickIntent) + + android.util.Log.d("PlaytivityWidget", "Made all elements clickable for ${activity.friendName}") + } else { + android.util.Log.w("PlaytivityWidget", "No userId for ${activity.friendName} - item will not be clickable") + } return views } @@ -298,6 +334,10 @@ class PlaytivityWidgetRemoteViewsFactory( ?: 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", "") ?: "" + + android.util.Log.d("PlaytivityWidget", "Loading activity $i - Name: $friendName, Track: $trackName, UserId: $userId") val timestamp = try { timestampString.toLongOrNull() ?: System.currentTimeMillis() @@ -312,7 +352,10 @@ class PlaytivityWidgetRemoteViewsFactory( } if (friendName.isNotEmpty() && trackName.isNotEmpty()) { - android.util.Log.d("PlaytivityWidget", "Activity $i: $friendName, cached image: $cachedImagePath") + android.util.Log.d("PlaytivityWidget", "Activity $i: $friendName (ID: '$userId'), track: $trackName, cached image: $cachedImagePath") + if (userId.isEmpty()) { + android.util.Log.w("PlaytivityWidget", "WARNING: Empty userId for friend $friendName at position $i") + } activities.add( ActivityItem( friendName = friendName, @@ -321,7 +364,8 @@ class PlaytivityWidgetRemoteViewsFactory( cachedImagePath = cachedImagePath, timestamp = timestamp, isCurrentlyPlaying = isCurrentlyPlaying, - activityType = activityType + activityType = activityType, + userId = userId ) ) } 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 index c8f8f52..79365f5 100644 --- a/android/app/src/main/res/layout/widget_activity_item.xml +++ b/android/app/src/main/res/layout/widget_activity_item.xml @@ -1,11 +1,14 @@ + android:padding="8dp" + android:clickable="true" + android:focusable="true"> - + android:tint="@color/widget_text_secondary" /> 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/screens/login_screen.dart b/lib/screens/login_screen.dart index 30d49f9..1f6780b 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -275,9 +275,9 @@ class LoginScreen extends StatelessWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), + Center(child: CircularProgressIndicator()), SizedBox(height: 16), - Text('Checking for updates...'), + Center(child: Text('Checking for updates...')), ], ), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3d7a704..d56b1ba 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -424,9 +424,9 @@ class _SettingsScreenState extends State { content: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), + Center(child: CircularProgressIndicator()), SizedBox(height: 16), - Text('Checking for updates...'), + Center(child: Text('Checking for updates...')), ], ), ); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index a00e9ce..98ed525 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -730,7 +730,7 @@ class UpdateService { content: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), + Center(child: CircularProgressIndicator()), SizedBox(height: 16), Text('Starting installation...'), ], @@ -1141,10 +1141,9 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { } String _formatSpeed(double bytesPerSecond) { - if (bytesPerSecond < 1024) return '${bytesPerSecond.toStringAsFixed(0)} B/s'; - if (bytesPerSecond < 1024 * 1024) return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; - if (bytesPerSecond < 1024 * 1024 * 1024) return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; - return '${(bytesPerSecond / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB/s'; + // 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) { @@ -1192,9 +1191,9 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { if (_currentProgress == null) { return Column( children: [ - const CircularProgressIndicator(), + const Center(child: CircularProgressIndicator()), const SizedBox(height: 16), - Text('Initializing download...'), + const Center(child: Text('Initializing download...')), ], ); } diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart index 56a3841..a4534a6 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -101,7 +101,7 @@ class WidgetService { final activity = activities[i]; // Reduce individual activity logging if (i < 3) { // Only log first 3 activities to reduce noise - print('๐Ÿ“Š Widget: Activity $i - ${activity.user.displayName}: ${activity.contentName}'); + print('๐Ÿ“Š Widget: Activity $i - ${activity.user.displayName} (ID: ${activity.user.id}): ${activity.contentName}'); } // Save via HomeWidget - batch operations for better performance @@ -265,7 +265,8 @@ class WidgetService { 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'; + print('๐Ÿ“Š friend_$i: $name - $track by $artist (ID: $userId)'); } if (count > 5) { print('๐Ÿ“Š ... and ${count - 5} more activities'); 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/widgets/activity_card.dart b/lib/widgets/activity_card.dart index 743c631..84ffbee 100644 --- a/lib/widgets/activity_card.dart +++ b/lib/widgets/activity_card.dart @@ -4,6 +4,7 @@ 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'; class ActivityCard extends StatelessWidget { final Activity activity; @@ -29,7 +30,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 +66,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( From 3d282df91a474a218dc6868aa493e3a3515855fd Mon Sep 17 00:00:00 2001 From: mliem2k Date: Sun, 29 Jun 2025 08:30:44 +0800 Subject: [PATCH 09/10] fix most of depreceated functions --- .gitignore | 2 + .../com/mliem/playtivity/MainActivity.kt | 4 - .../playtivity/widget/ImageCacheService.kt | 24 +- .../playtivity/widget/ImageDownloader.kt | 6 +- .../widget/PlaytivityWidgetProvider.kt | 79 +--- android/gradle.properties | 25 +- lib/main.dart | 58 +-- lib/providers/auth_provider.dart | 235 +++++------ lib/providers/spotify_provider.dart | 37 +- lib/screens/home_screen.dart | 30 +- lib/screens/login_screen.dart | 29 +- lib/screens/profile_screen.dart | 92 ++-- lib/services/app_logger.dart | 4 +- lib/services/http_interceptor.dart | 22 +- lib/services/spotify_buddy_service.dart | 397 ++++++++++-------- lib/services/spotify_service.dart | 13 +- lib/services/widget_service.dart | 105 ++--- lib/utils/auth_utils.dart | 1 + lib/utils/theme.dart | 2 +- lib/utils/update_launcher.dart | 8 +- lib/utils/version_utils.dart | 87 ++-- lib/widgets/activity_card.dart | 9 +- lib/widgets/activity_skeleton.dart | 19 +- lib/widgets/currently_playing_card.dart | 28 +- lib/widgets/refresh_indicator_bar.dart | 12 +- lib/widgets/spotify_webview_login.dart | 103 ++++- lib/widgets/update_dialog_handler.dart | 198 +++++++++ nightly-apk.js | 58 ++- package.json | 1 + pubspec.yaml | 2 +- 30 files changed, 1009 insertions(+), 681 deletions(-) create mode 100644 lib/widgets/update_dialog_handler.dart diff --git a/.gitignore b/.gitignore index 48d063a..e2a52fd 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ android/app/upload-keystore.jks *.txt *.apk /nightly +.pub-cache-hash +delete-releases.js 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 72967fc..6567640 100644 --- a/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mliem/playtivity/MainActivity.kt @@ -161,8 +161,6 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti val flutterActivitiesCount = flutterPrefs.getString("flutter.activities_count", "0") val homeWidgetActivitiesCount = homeWidgetPrefs.getString("activities_count", "0") - android.util.Log.d("PlaytivityWidget", "Widget update triggered - Flutter: $flutterActivitiesCount, HomeWidget: $homeWidgetActivitiesCount activities") - // Start image caching service first ImageCacheService.startImageCaching(this) @@ -174,8 +172,6 @@ class MainActivity : FlutterActivity() { private val WIDGET_CHANNEL = "playti for (appWidgetId in appWidgetIds) { PlaytivityWidgetProvider.updateAppWidget(this, appWidgetManager, appWidgetId) } - - android.util.Log.d("PlaytivityWidget", "Widget update completed") } catch (e: Exception) { android.util.Log.e("PlaytivityWidget", "Error updating widget", e) } 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 2a587e3..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 @@ -26,24 +26,22 @@ class ImageCacheService : IntentService("ImageCacheService") { } } - private fun cacheAllFriendImages() { try { - android.util.Log.d("ImageCacheService", "Starting to cache friend images") + private fun cacheAllFriendImages() { + try { + 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 { var cachedCount = 0 - // Cache images for all friends (no longer limited to 5) + 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()) { - // Only log first few to reduce noise - if (i < 2) { - android.util.Log.d("ImageCacheService", "Caching image for friend $i: $friendName") - } val cachedPath = ImageDownloader.downloadAndCacheImage(this@ImageCacheService, friendImage, i) if (cachedPath != null) { @@ -58,16 +56,14 @@ class ImageCacheService : IntentService("ImageCacheService") { .putString("flutter.friend_${i}_cached_image", cachedPath) .apply() - android.util.Log.d("ImageCacheService", "Saved cached image path for $friendName: $cachedPath") cachedCount++ } else { - android.util.Log.w("ImageCacheService", "Failed to cache image for $friendName") + failedCount++ } - } } - - if (cachedCount > 0) { - android.util.Log.d("ImageCacheService", "Cached $cachedCount images total") + } } + + } // Update all widgets after caching images @@ -79,8 +75,6 @@ class ImageCacheService : IntentService("ImageCacheService") { 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/PlaytivityWidgetProvider.kt b/android/app/src/main/kotlin/com/mliem/playtivity/widget/PlaytivityWidgetProvider.kt index 02125cc..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 @@ -9,6 +9,7 @@ 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 @@ -65,8 +66,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetId: Int ) { - android.util.Log.d("PlaytivityWidget", "Updating widget $appWidgetId") - // Create RemoteViews val views = RemoteViews(context.packageName, R.layout.widget_layout) @@ -82,8 +81,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { 0 } - android.util.Log.d("PlaytivityWidget", "Widget data: activitiesCount=$activitiesCount") - if (activitiesCount > 0) { // Parse all activities val activities = mutableListOf() @@ -106,8 +103,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { val userId = prefs.getString("friend_${i}_user_id", "") ?: flutterPrefs.getString("flutter.friend_${i}_user_id", "") ?: "" - android.util.Log.d("PlaytivityWidget", "Loading activity $i - Name: $friendName, Track: $trackName, UserId: $userId") - val timestamp = try { timestampString.toLongOrNull() ?: System.currentTimeMillis() } catch (e: Exception) { @@ -160,7 +155,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE ) views.setPendingIntentTemplate(R.id.activities_list, clickPendingTemplate) - android.util.Log.d("PlaytivityWidget", "Set pending intent template for ListView") // Set empty view views.setEmptyView(R.id.activities_list, R.id.empty_state) @@ -171,11 +165,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { // Update currently playing count val currentlyPlayingCount = sortedActivities.count { it.isCurrentlyPlaying } - if (currentlyPlayingCount > 0) { - android.util.Log.d("PlaytivityWidget", "Showing $currentlyPlayingCount live activities") - } - - android.util.Log.d("PlaytivityWidget", "Showing ${sortedActivities.size} activities, $currentlyPlayingCount currently playing") } else { showEmptyState(views) } @@ -190,7 +179,6 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_title, mainPendingIntent) - android.util.Log.d("PlaytivityWidget", "Set main app click on header title") // Set refresh button click intent val refreshIntent = Intent(context, MainActivity::class.java) @@ -201,12 +189,9 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE ) views.setOnClickPendingIntent(R.id.refresh_button, refreshPendingIntent) - android.util.Log.d("PlaytivityWidget", "Set refresh button click handler") // Update the widget appWidgetManager.updateAppWidget(appWidgetId, views) - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.activities_list) - android.util.Log.d("PlaytivityWidget", "Widget update completed") } private fun showEmptyState(views: RemoteViews) { @@ -218,13 +203,13 @@ class PlaytivityWidgetProvider : AppWidgetProvider() { class PlaytivityWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { - return PlaytivityWidgetRemoteViewsFactory(this.applicationContext, intent) + return PlaytivityWidgetItemFactory(applicationContext, intent) } } -class PlaytivityWidgetRemoteViewsFactory( +class PlaytivityWidgetItemFactory( private val context: Context, - intent: Intent + private val intent: Intent ) : RemoteViewsService.RemoteViewsFactory { private var activities = mutableListOf() @@ -270,28 +255,16 @@ class PlaytivityWidgetRemoteViewsFactory( } // Load friend image - loadFriendImage(views, activity.cachedImagePath, activity.friendName) + loadFriendImage(views, activity.cachedImagePath) // Set click intent to open friend profile only if we have a valid user ID if (activity.userId.isNotEmpty()) { - val clickIntent = Intent() - clickIntent.action = "OPEN_FRIEND_PROFILE" - clickIntent.putExtra("friendUserId", activity.userId) - clickIntent.putExtra("friendName", activity.friendName) - android.util.Log.d("PlaytivityWidget", "Setting click intent for ${activity.friendName} with userId: ${activity.userId}") - - // Make multiple elements clickable for better UX - views.setOnClickFillInIntent(R.id.friend_image, clickIntent) - views.setOnClickFillInIntent(R.id.track_name, clickIntent) - views.setOnClickFillInIntent(R.id.friend_artist, clickIntent) - views.setOnClickFillInIntent(R.id.timestamp, clickIntent) - - // Make the entire item clickable - views.setOnClickFillInIntent(R.id.widget_activity_item, clickIntent) - - android.util.Log.d("PlaytivityWidget", "Made all elements clickable for ${activity.friendName}") - } else { - android.util.Log.w("PlaytivityWidget", "No userId for ${activity.friendName} - item will not be clickable") + 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) } return views @@ -306,6 +279,8 @@ class PlaytivityWidgetRemoteViewsFactory( 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) @@ -313,12 +288,10 @@ class PlaytivityWidgetRemoteViewsFactory( 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", "Loading $activitiesCount activities for widget") - activities.clear() - for (i in 0 until activitiesCount) { val friendName = prefs.getString("friend_${i}_name", "") ?: flutterPrefs.getString("flutter.friend_${i}_name", "") ?: "" @@ -337,8 +310,6 @@ class PlaytivityWidgetRemoteViewsFactory( val userId = prefs.getString("friend_${i}_user_id", "") ?: flutterPrefs.getString("flutter.friend_${i}_user_id", "") ?: "" - android.util.Log.d("PlaytivityWidget", "Loading activity $i - Name: $friendName, Track: $trackName, UserId: $userId") - val timestamp = try { timestampString.toLongOrNull() ?: System.currentTimeMillis() } catch (e: Exception) { @@ -352,10 +323,6 @@ class PlaytivityWidgetRemoteViewsFactory( } if (friendName.isNotEmpty() && trackName.isNotEmpty()) { - android.util.Log.d("PlaytivityWidget", "Activity $i: $friendName (ID: '$userId'), track: $trackName, cached image: $cachedImagePath") - if (userId.isEmpty()) { - android.util.Log.w("PlaytivityWidget", "WARNING: Empty userId for friend $friendName at position $i") - } activities.add( ActivityItem( friendName = friendName, @@ -379,39 +346,25 @@ class PlaytivityWidgetRemoteViewsFactory( else -> b.timestamp.compareTo(a.timestamp) } } - - android.util.Log.d("PlaytivityWidget", "Loaded ${activities.size} activities for ListView") } - private fun loadFriendImage(views: RemoteViews, cachedImagePath: String, friendName: String) { - android.util.Log.d("PlaytivityWidget", "Loading image for $friendName, path: $cachedImagePath") - + private fun loadFriendImage(views: RemoteViews, cachedImagePath: String) { if (cachedImagePath.isNotEmpty()) { val file = File(cachedImagePath) - android.util.Log.d("PlaytivityWidget", "File exists: ${file.exists()}, readable: ${file.canRead()}, size: ${file.length()}") - if (file.exists() && file.canRead() && file.length() > 0) { try { val bitmap = BitmapFactory.decodeFile(cachedImagePath) if (bitmap != null && !bitmap.isRecycled) { - android.util.Log.d("PlaytivityWidget", "Successfully loaded bitmap for $friendName (${bitmap.width}x${bitmap.height})") views.setImageViewBitmap(R.id.friend_image, bitmap) return - } else { - android.util.Log.w("PlaytivityWidget", "Failed to decode bitmap for $friendName") } } catch (e: Exception) { - android.util.Log.e("PlaytivityWidget", "Exception loading image for $friendName", e) + android.util.Log.e("PlaytivityWidget", "Error loading friend image", e) } - } else { - android.util.Log.w("PlaytivityWidget", "Image file not accessible for $friendName: exists=${file.exists()}, readable=${file.canRead()}, size=${file.length()}") } - } else { - android.util.Log.d("PlaytivityWidget", "No cached image path for $friendName") } // Fallback to default icon - android.util.Log.d("PlaytivityWidget", "Using default icon for $friendName") views.setImageViewResource(R.id.friend_image, R.drawable.ic_person) } } \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 3f694f5..f9246fd 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,27 @@ -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 diff --git a/lib/main.dart b/lib/main.dart index ca1f024..03b944b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,8 @@ 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(); @@ -46,12 +48,12 @@ Future _checkForUpdatesOnStartup() async { // Store the result for later use if (updateResult.hasUpdate) { // We'll handle the update notification in the app UI later - print('Update available: ${updateResult.updateInfo?.version}'); + AppLogger.info('Update available: ${updateResult.updateInfo?.version}'); } } } catch (e) { // Ignore errors during startup, we don't want to block app launch - print('Error checking for updates on startup: $e'); + AppLogger.error('Error checking for updates on startup', e); } } @@ -79,19 +81,19 @@ class MyApp extends StatelessWidget { 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(); }, }, @@ -126,12 +128,12 @@ 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 @@ -139,7 +141,7 @@ class _AppWrapperState extends State { // 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: SafeArea( child: Center( @@ -161,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(); } }, @@ -193,7 +195,7 @@ class _MainNavigationScreenState extends State with Widget @override void initState() { super.initState(); - print('๐Ÿ  MainNavigationScreen initialized'); + AppLogger.info('๐Ÿ  MainNavigationScreen initialized'); // Add lifecycle observer WidgetsBinding.instance.addObserver(this); @@ -215,7 +217,7 @@ class _MainNavigationScreenState extends State with Widget super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { - print('๐Ÿ“ฑ App resumed, verifying authentication...'); + 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) { @@ -223,24 +225,24 @@ class _MainNavigationScreenState extends State with Widget // Skip verification if user is actively logging in or if auth provider is not initialized if (authProvider.isLoading || !authProvider.isInitialized) { - print('โš ๏ธ Skipping auth verification - login in progress or not initialized'); + AppLogger.info('โš ๏ธ Skipping auth verification - login in progress or not initialized'); return; } // Only verify if we think we should be authenticated if (authProvider.isAuthenticated) { - print('๐Ÿ”„ Verifying existing authentication...'); + AppLogger.info('๐Ÿ”„ Verifying existing authentication...'); final isValid = await authProvider.verifyAndRefreshAuth(); if (!isValid) { - print('โš ๏ธ Authentication invalid after app resume - clearing state'); + 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 { - print('โœ… Authentication verified successfully after app resume'); + AppLogger.info('โœ… Authentication verified successfully after app resume'); } } else { - print('โ„น๏ธ No authentication to verify - user not logged in'); + AppLogger.info('โ„น๏ธ No authentication to verify - user not logged in'); } } }); @@ -250,15 +252,15 @@ class _MainNavigationScreenState extends State with Widget 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) { @@ -359,17 +361,19 @@ class _UpdateCheckerWrapperState extends State<_UpdateCheckerWrapper> { // 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) { - print('Error checking for updates: $e'); + AppLogger.error('Error checking for updates', e); } } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 58bd11b..a279a11 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -5,6 +5,7 @@ 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 { @@ -27,7 +28,7 @@ class AuthProvider extends ChangeNotifier { bool get isAuthenticated { if (!_isInitialized) { - print('๐Ÿ” isAuthenticated: false (not initialized)'); + AppLogger.auth('isAuthenticated: false (not initialized)'); return false; } @@ -41,13 +42,6 @@ class AuthProvider extends ChangeNotifier { final tokenAuth = hasToken && hasUser && buddyServiceHasToken; - print('๐Ÿ” isAuthenticated evaluation:'); - print(' - Local token: $hasToken'); - print(' - Local user: $hasUser'); - print(' - Buddy service token: $buddyServiceHasToken'); - print(' - User: ${_currentUser?.displayName ?? 'none'}'); - print(' - Final result: $tokenAuth'); - return tokenAuth; } bool get isLoading => _isLoading; @@ -58,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 @@ -71,30 +65,30 @@ 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 { - print('๐Ÿ”„ Validating restored authentication...'); + AppLogger.auth('Validating restored authentication...'); final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); if (testUser != null && testUser.id == _currentUser!.id) { - print('โœ… Restored authentication is valid'); + AppLogger.auth('Restored authentication is valid'); } else { - print('โš ๏ธ Restored authentication validation failed - clearing stored data'); + AppLogger.warning('Restored authentication validation failed - clearing stored data'); await _clearStoredData(); _bearerToken = null; _headers = null; _currentUser = null; } } catch (e) { - print('โš ๏ธ Authentication validation failed: $e - clearing stored data'); + AppLogger.warning('Authentication validation failed: $e - clearing stored data'); await _clearStoredData(); _bearerToken = null; _headers = null; @@ -102,42 +96,42 @@ class AuthProvider extends ChangeNotifier { } } else { // Fetch user profile using Bearer token - print('๐Ÿ”„ Fetching user profile with Bearer token...'); + AppLogger.auth('Fetching user profile with Bearer token...'); try { _currentUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); if (_currentUser != null) { - print('โœ… Successfully loaded user profile: ${_currentUser!.displayName}'); + AppLogger.auth('Successfully loaded user profile: ${_currentUser!.displayName}'); // Save the user profile for next time await _prefs.setString(_userKey, json.encode(_currentUser!.toJson())); } else { - print('โš ๏ธ Failed to load user profile - clearing stored data'); + AppLogger.warning('Failed to load user profile - clearing stored data'); await _clearStoredData(); _bearerToken = null; _headers = null; } } catch (e) { - print('โš ๏ธ Failed to load user profile: $e - clearing stored data'); + 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 @@ -151,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() { @@ -172,14 +166,14 @@ 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) { @@ -194,16 +188,16 @@ class AuthProvider extends ChangeNotifier { _buddyService.setBearerToken(bearerToken, headers); // Verify the buddy service has the token properly set - print('๐Ÿ” Verifying buddy service token after setting...'); + 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('โœ… Buddy service token verified: ${buddyServiceToken.substring(0, 20)}...'); + AppLogger.auth('Buddy service token verified: ${buddyServiceToken.substring(0, 20)}...'); } // Fetch user profile using Bearer token with retry logic - print('๐Ÿ”„ Fetching user profile with Bearer token...'); + 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) @@ -212,15 +206,15 @@ class AuthProvider extends ChangeNotifier { try { userProfile = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); if (userProfile != null) { - print('โœ… Successfully loaded user profile on attempt $attempt: ${userProfile.displayName}'); + AppLogger.auth('Successfully loaded user profile on attempt $attempt: ${userProfile.displayName}'); break; } } catch (e) { - print('โš ๏ธ Failed to load user profile on attempt $attempt: $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; - print('๐Ÿ”„ Retrying in ${delayMs}ms...'); + AppLogger.auth('Retrying in ${delayMs}ms...'); await Future.delayed(Duration(milliseconds: delayMs)); } } @@ -235,17 +229,17 @@ class AuthProvider extends ChangeNotifier { // 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'); // 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'}'); + AppLogger.auth('AuthProvider state after completion:'); + AppLogger.auth(' - isAuthenticated: $isAuthenticated'); + AppLogger.auth(' - isInitialized: $_isInitialized'); + AppLogger.auth(' - isLoading: $_isLoading'); + AppLogger.auth(' - currentUser: ${_currentUser?.displayName ?? 'null'}'); // Notify listeners once with the final state notifyListeners(); @@ -261,20 +255,20 @@ class AuthProvider extends ChangeNotifier { final finalBuddyServiceToken = _buddyService.getBearerToken(); final buddyServiceHasToken = finalBuddyServiceToken != null && finalBuddyServiceToken.isNotEmpty; - print('๐Ÿ” Final verification check:'); - print(' - hasToken: $hasToken'); - print(' - hasUser: $hasUser'); - print(' - isInitialized: $isInitComplete'); - print(' - buddyServiceHasToken: $buddyServiceHasToken'); + 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'); } - print('โœ… Authentication flow completed successfully'); + 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; @@ -291,28 +285,28 @@ class AuthProvider extends ChangeNotifier { } 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); @@ -322,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; @@ -340,12 +334,12 @@ class AuthProvider extends ChangeNotifier { // 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(); @@ -361,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 @@ -375,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; @@ -390,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(); } } @@ -417,22 +411,22 @@ 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 { - print('๐Ÿ”„ Resetting authentication state after error...'); + AppLogger.auth('Resetting authentication state after error...'); try { // Clear all stored authentication data @@ -448,12 +442,12 @@ class AuthProvider extends ChangeNotifier { _isLoading = false; // Keep _isInitialized = true so the app doesn't show loading screen - print('โœ… Authentication state reset completed'); - print('๐Ÿ” Post-reset state:'); - print(' - isAuthenticated: $isAuthenticated'); - print(' - isInitialized: $_isInitialized'); - print(' - bearerToken: ${_bearerToken ?? 'null'}'); - print(' - currentUser: ${_currentUser?.displayName ?? 'null'}'); + 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(); @@ -463,7 +457,7 @@ class AuthProvider extends ChangeNotifier { notifyListeners(); } catch (e) { - print('โŒ Error during authentication state reset: $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; @@ -475,88 +469,84 @@ class AuthProvider extends ChangeNotifier { /// 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 - await _clearStoredData(); - _buddyService.clearBearerToken(); - - // Clear any SpotifyProvider state that might have cached data + // Clear SpotifyProvider state first try { final spotifyProvider = Provider.of(context, listen: false); - spotifyProvider.clearData(); spotifyProvider.clearError(); spotifyProvider.clearAuthError(); - print('โœ… Cleared SpotifyProvider cached state'); + AppLogger.auth('Cleared SpotifyProvider cached state'); } catch (e) { - print('โš ๏ธ Could not clear SpotifyProvider state: $e'); + AppLogger.warning('Could not clear SpotifyProvider state: $e'); } + // Clear all stored data + await _clearStoredData(); + // 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 { - print('๐Ÿ” Verifying and refreshing authentication state...'); + AppLogger.auth('Verifying and refreshing authentication state...'); if (!_isInitialized) { - print('โš ๏ธ Auth not initialized yet, skipping verification'); + 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) { - print('โš ๏ธ Missing basic auth data (token or user)'); + AppLogger.warning('Missing basic auth data (token or user)'); return false; } try { // Test the current token by making an API call - print('๐Ÿ”„ Testing current authentication with API call...'); + AppLogger.auth('Testing current authentication with API call...'); final testUser = await _buddyService.getCurrentUserProfileWithToken(_bearerToken!); if (testUser != null && testUser.id == _currentUser!.id) { - print('โœ… Authentication verified successfully'); + AppLogger.auth('Authentication verified successfully'); // Update user info in case anything changed _currentUser = testUser; - await _saveStoredData(); - - // Ensure state is properly set - _isLoading = false; - _isInitialized = true; + await _prefs.setString(_userKey, json.encode(_currentUser!.toJson())); notifyListeners(); return true; } else { - print('โš ๏ธ Authentication test failed - user mismatch or null'); + AppLogger.warning('Authentication test failed - user mismatch or null'); await _clearStoredData(); _bearerToken = null; _headers = null; @@ -565,14 +555,13 @@ class AuthProvider extends ChangeNotifier { return false; } } catch (e) { - print('โŒ Authentication verification failed: $e'); + AppLogger.error('Authentication verification failed', e); // Clear invalid auth data await _clearStoredData(); _bearerToken = null; _headers = null; _currentUser = null; - _isLoading = false; notifyListeners(); return false; diff --git a/lib/providers/spotify_provider.dart b/lib/providers/spotify_provider.dart index b28e80f..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,7 +49,7 @@ 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; @@ -94,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(); @@ -175,33 +176,24 @@ 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; activities = await _buddyService.getFriendActivity( + final useFastLoad = showSkeleton; + activities = await _buddyService.getFriendActivity( fastLoad: useFastLoad, onActivitiesUpdate: (updatedActivities) { // Update activities progressively as track durations are fetched _friendsActivities = updatedActivities; notifyListeners(); - // Reduced logging frequency for progressive updates - if (updatedActivities.length <= 5 || updatedActivities.length % 10 == 0) { - 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 { @@ -320,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(); @@ -333,7 +325,8 @@ class SpotifyProvider extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 500)); try { - print('๐Ÿ”„ Enhancing activities with detailed info...'); // Load detailed activities (with duration checks) + 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 onActivitiesUpdate: (updatedActivities) { @@ -342,7 +335,7 @@ class SpotifyProvider extends ChangeNotifier { notifyListeners(); // Reduced logging frequency for background enhancements if (updatedActivities.length <= 5 || updatedActivities.length % 10 == 0) { - print('๐Ÿ”„ Background enhancement update: ${updatedActivities.length} activities updated'); + AppLogger.debug('Background enhancement update: ${updatedActivities.length} activities updated'); } }, ); @@ -351,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(); @@ -381,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 f091dcb..252939f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -8,6 +8,7 @@ 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 { @@ -49,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; } @@ -60,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'); } } @@ -70,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; } @@ -81,7 +82,7 @@ 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'); } } @@ -89,17 +90,28 @@ class _HomeScreenState extends State { @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), + ), ), ), ), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 1f6780b..82ba21b 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -4,6 +4,7 @@ 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}); @@ -42,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), @@ -122,7 +123,7 @@ class LoginScreen extends StatelessWidget { 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), ), @@ -171,29 +172,29 @@ class LoginScreen extends StatelessWidget { 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)); // Verify authentication was successful before proceeding if (authProvider.isAuthenticated) { - print('โœ… Authentication verified - ready to proceed'); + 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 { - print('โŒ Authentication failed - auth provider not authenticated'); + 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)) { @@ -225,7 +226,7 @@ class LoginScreen extends StatelessWidget { // Double-check authentication state before navigating if (authProvider.isAuthenticated) { - print('โœ… Login successful, navigating to home screen...'); + AppLogger.auth('Login successful, navigating to home screen...'); if (context.mounted) { // Use pushNamedAndRemoveUntil to ensure clean navigation stack Navigator.of(context).pushNamedAndRemoveUntil( @@ -234,7 +235,7 @@ class LoginScreen extends StatelessWidget { ); } } else { - print('โŒ Authentication lost after successful login - showing error'); + AppLogger.auth('Authentication lost after successful login - showing error'); authProvider.cancelLogin(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -247,11 +248,11 @@ class LoginScreen extends StatelessWidget { } } else { // Login was cancelled or failed - print('โ„น๏ธ Login was cancelled or failed'); + AppLogger.auth('Login was cancelled or failed'); authProvider.cancelLogin(); } } catch (e) { - print('โŒ Error in login flow: $e'); + AppLogger.auth('Error in login flow', e); authProvider.cancelLogin(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -298,6 +299,7 @@ class LoginScreen extends StatelessWidget { if (updateResult.hasUpdate && updateResult.updateInfo != null) { // Show update available dialog + if (!context.mounted) return; final shouldUpdate = await _showUpdateDialog( context, updateResult.updateInfo!, @@ -320,6 +322,7 @@ class LoginScreen extends StatelessWidget { 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.'), @@ -421,9 +424,9 @@ class LoginScreen extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.1), + color: Colors.orange.withAlpha(26), borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange.withOpacity(0.3)), + border: Border.all(color: Colors.orange.withAlpha(77)), ), child: Row( children: [ diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index a49bfc1..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,34 +53,46 @@ 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'); } + 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()), + ); + }, + ), + ], + ), ), ), ), @@ -105,24 +119,32 @@ class _ProfileScreenState extends State with TickerProviderStateM 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), diff --git a/lib/services/app_logger.dart b/lib/services/app_logger.dart index b7e0705..3a9d25e 100644 --- a/lib/services/app_logger.dart +++ b/lib/services/app_logger.dart @@ -1,4 +1,4 @@ -import 'package:logger/logger.dart'; +import 'package:logger/logger.dart' show Logger, ProductionFilter, PrettyPrinter, ConsoleOutput, DateTimeFormat; /// Centralized logging service for the application /// Replaces print() statements with structured logging @@ -15,7 +15,7 @@ class AppLogger { lineLength: 120, // Width of the output colors: true, // Colorful log messages printEmojis: true, // Print emoji for each log level - printTime: false, // Should each log print contain a timestamp + dateTimeFormat: DateTimeFormat.none, // Format for timestamps in log messages ), output: ConsoleOutput(), ); diff --git a/lib/services/http_interceptor.dart b/lib/services/http_interceptor.dart index 39006bd..a5982c6 100644 --- a/lib/services/http_interceptor.dart +++ b/lib/services/http_interceptor.dart @@ -18,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( @@ -26,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; @@ -47,16 +54,23 @@ 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 { + + /// Handle response and check for authentication errors + 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; + } if (_currentContext != null && _currentContext!.mounted) { AppLogger.http('Context available - redirecting to login'); diff --git a/lib/services/spotify_buddy_service.dart b/lib/services/spotify_buddy_service.dart index 4e016cc..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,39 +192,37 @@ 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'); return null; // Return null instead of throwing exception } @@ -230,13 +231,24 @@ 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() { - print('๐Ÿ—‘๏ธ Clearing all SpotifyBuddyService state...'); + AppLogger.spotify('๐Ÿ—‘๏ธ Clearing all SpotifyBuddyService state...'); // Clear authentication tokens _directBearerToken = null; _completeCookieString = null; + _clientToken = null; // Clear all cached data to prevent stale state clearBuddyListCache(); @@ -245,7 +257,7 @@ class SpotifyBuddyService { _cacheModified = false; _artistCacheModified = false; - print('โœ… Cleared Bearer token, headers, and all cached data'); + AppLogger.spotify('โœ… Cleared Bearer token, headers, and all cached data'); } @@ -258,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; } @@ -277,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; } } @@ -301,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; } @@ -324,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(); @@ -368,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!; } @@ -379,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'; @@ -393,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 @@ -413,8 +421,6 @@ class SpotifyBuddyService { headers: retryHeaders, ); - print('๐Ÿ“ก Retry response: ${retryResponse.statusCode}'); - if (retryResponse.statusCode == 200) { final activities = await _parseActivityResponse( retryResponse.body, @@ -426,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'); } } @@ -444,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 []; } }, @@ -466,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 = []; @@ -480,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) @@ -501,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 ?? '', @@ -526,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'] ?? ''; @@ -536,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 @@ -574,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); @@ -596,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 []; @@ -616,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); @@ -629,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) { @@ -664,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); @@ -676,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'); } } @@ -686,7 +665,7 @@ class SpotifyBuddyService { _cacheModified = false; } - print('โœ… Completed progressive track duration fetch'); + AppLogger.spotify('โœ… Completed progressive track duration fetch'); }); } @@ -812,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: () { @@ -823,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 @@ -843,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 = { @@ -857,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 { @@ -887,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 = { @@ -907,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"', @@ -919,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!, }; @@ -931,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( @@ -956,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; } } @@ -986,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 []; } @@ -1008,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?; } @@ -1031,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 []; } } @@ -1051,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 []; } @@ -1070,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?; } @@ -1092,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( @@ -1107,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 []; } } @@ -1142,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) { @@ -1176,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) { @@ -1185,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, @@ -1255,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(); } } @@ -1274,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/widget_service.dart b/lib/services/widget_service.dart index a4534a6..3f2617a 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -5,6 +5,7 @@ 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 { @@ -25,23 +26,19 @@ class WidgetService { // 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) { + if (data != null) { final action = data.host; 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 { @@ -49,13 +46,8 @@ class WidgetService { // Save to both flutter prefix and regular HomeWidget prefix for maximum compatibility await prefs.setString('flutter.$key', value); await prefs.setString(key, value); // Also save without flutter prefix - - // Only log key operations to reduce noise - if (key == 'activities_count' || key == 'last_update') { - print('๐Ÿ“Š Widget data saved: flutter.$key = $value'); - } } catch (e) { - print('โŒ Error saving to SharedPreferences: $e'); + AppLogger.error('Error saving to SharedPreferences', e); } } @@ -66,13 +58,11 @@ class WidgetService { List? friendsActivities, }) async { try { - // Save friends' activities (show all activities, no longer limited to 5) + // 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'); - // 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++) { @@ -96,13 +86,10 @@ class WidgetService { await _saveToSharedPreferences('friend_${i}_is_currently_playing', ''); await _saveToSharedPreferences('friend_${i}_activity_type', ''); } - // Now save all the actual activities + + // Now save all the actual activities for (int i = 0; i < activities.length; i++) { final activity = activities[i]; - // Reduce individual activity logging - if (i < 3) { // Only log first 3 activities to reduce noise - print('๐Ÿ“Š Widget: Activity $i - ${activity.user.displayName} (ID: ${activity.user.id}): ${activity.contentName}'); - } // Save via HomeWidget - batch operations for better performance await HomeWidget.saveWidgetData('friend_${i}_name', activity.user.displayName); @@ -130,15 +117,7 @@ class WidgetService { // 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}'); - - if (activities.length <= 3) { - print('๐Ÿ“Š Widget: Successfully saved all ${activities.length} activities'); - } else { - print('๐Ÿ“Š Widget: Successfully saved ${activities.length} activities (showing first 3 in logs)'); - } - } else { - print('๐Ÿ“Š Widget: No activities to save - clearing widget data'); + } else { // No activities - clear all slots and set count to 0 await HomeWidget.saveWidgetData('activities_count', '0'); await _saveToSharedPreferences('activities_count', '0'); @@ -166,41 +145,31 @@ class WidgetService { await _saveToSharedPreferences('friend_${i}_activity_type', ''); } } - // Save last update timestamp - await HomeWidget.saveWidgetData('last_update', DateTime.now().toIso8601String()); + await _saveToSharedPreferences('last_update', DateTime.now().toIso8601String()); // Reduced delay for faster widget updates await Future.delayed(const Duration(milliseconds: 100)); - print('๐Ÿ“Š Widget: About to call updateWidget()'); - print('๐Ÿ“Š Widget: Using androidName: $_androidWidgetName'); - // Trigger simple widget update try { await HomeWidget.updateWidget( qualifiedAndroidName: _androidWidgetName, iOSName: _iOSWidgetName, ); - print('๐Ÿ“ฑ Widget updated successfully'); - // Force image caching after a short delay Future.delayed(const Duration(milliseconds: 500), () async { try { await _channel.invokeMethod('cacheImages'); - print('๐Ÿ“ธ Image caching triggered'); } catch (e) { - print('โŒ Image caching failed: $e'); + AppLogger.error('Image caching failed', e); } }); - } catch (e) { - print('โŒ Widget update failed: $e'); - // Data is still saved, widget may update on next system refresh - print('๐Ÿ“ฑ Data saved to SharedPreferences - widget will update on next refresh'); + AppLogger.error('Widget update failed', e); } } catch (e) { - print('โŒ Error updating widget: $e'); + AppLogger.error('Error updating widget', e); } } @@ -218,7 +187,8 @@ class WidgetService { return false; } } - // Clear widget data + + // Clear widget data static Future clearWidgetData() async { try { // Clear activities count @@ -243,20 +213,21 @@ class WidgetService { 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 only first 5 activities to reduce noise final count = int.tryParse(activitiesCount) ?? 0; @@ -266,24 +237,24 @@ class WidgetService { final track = prefs.getString('flutter.friend_${i}_track') ?? 'null'; final artist = prefs.getString('flutter.friend_${i}_artist') ?? 'null'; final userId = prefs.getString('flutter.friend_${i}_user_id') ?? 'null'; - print('๐Ÿ“Š friend_$i: $name - $track by $artist (ID: $userId)'); + AppLogger.widget('friend_$i: $name - $track by $artist (ID: $userId)'); } if (count > 5) { - print('๐Ÿ“Š ... and ${count - 5} more activities'); + 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); } } @@ -292,7 +263,7 @@ class WidgetService { final debugInfo = {}; try { - print('๐Ÿž === RELEASE WIDGET DEBUG ==='); + AppLogger.widget('=== RELEASE WIDGET DEBUG ==='); // 1. Check SharedPreferences data final prefs = await SharedPreferences.getInstance(); @@ -305,8 +276,8 @@ class WidgetService { debugInfo['widget_keys'] = widgetKeys.length; debugInfo['widget_key_list'] = widgetKeys; - print('๐Ÿž Total keys in SharedPreferences: ${allKeys.length}'); - print('๐Ÿž Widget-related keys: ${widgetKeys.length}'); + 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'); @@ -315,8 +286,8 @@ class WidgetService { debugInfo['flutter_activities_count'] = flutterActivitiesCount; debugInfo['home_widget_activities_count'] = homeWidgetActivitiesCount; - print('๐Ÿž Flutter activities count: $flutterActivitiesCount'); - print('๐Ÿž HomeWidget activities count: $homeWidgetActivitiesCount'); + AppLogger.widget('Flutter activities count: $flutterActivitiesCount'); + AppLogger.widget('HomeWidget activities count: $homeWidgetActivitiesCount'); // 3. Test method channel debugInfo['method_channel_available'] = false; @@ -324,10 +295,10 @@ class WidgetService { final result = await _channel.invokeMethod('updateWidget'); debugInfo['method_channel_available'] = true; debugInfo['method_channel_result'] = result; - print('๐Ÿž Method channel works: $result'); + AppLogger.widget('Method channel works: $result'); } catch (e) { debugInfo['method_channel_error'] = e.toString(); - print('๐Ÿž Method channel failed: $e'); + AppLogger.error('Method channel failed', e); } // 4. Test HomeWidget integration @@ -335,17 +306,17 @@ class WidgetService { try { await HomeWidget.setAppGroupId('group.com.mliem.playtivity'); debugInfo['home_widget_available'] = true; - print('๐Ÿž HomeWidget integration works'); + AppLogger.widget('HomeWidget integration works'); } catch (e) { debugInfo['home_widget_error'] = e.toString(); - print('๐Ÿž HomeWidget integration failed: $e'); + AppLogger.error('HomeWidget integration failed', e); } - print('๐Ÿž === END RELEASE DEBUG ==='); + AppLogger.widget('=== END RELEASE DEBUG ==='); } catch (e) { debugInfo['debug_error'] = e.toString(); - print('โŒ Debug error: $e'); + AppLogger.error('Debug error', e); } return debugInfo; diff --git a/lib/utils/auth_utils.dart b/lib/utils/auth_utils.dart index 6357845..87ecc15 100644 --- a/lib/utils/auth_utils.dart +++ b/lib/utils/auth_utils.dart @@ -41,6 +41,7 @@ class AuthUtils { await authProvider.logout(); // Show WebView login + if (!context.mounted) return false; final result = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => SpotifyWebViewLogin( diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index a9e19bd..28d5f1e 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -76,7 +76,7 @@ class AppTheme { primaryColor: spotifyGreen, scaffoldBackgroundColor: spotifyBlack, appBarTheme: AppBarTheme( - backgroundColor: spotifyBlack.withOpacity(0.85), // Transparent effect + backgroundColor: spotifyBlack.withOpacity(0.9), // Increased opacity for better visibility in dark mode foregroundColor: spotifyWhite, elevation: 0, scrolledUnderElevation: 0, diff --git a/lib/utils/update_launcher.dart b/lib/utils/update_launcher.dart index 157f8d8..ec19031 100644 --- a/lib/utils/update_launcher.dart +++ b/lib/utils/update_launcher.dart @@ -60,10 +60,10 @@ class UpdateLauncher { // Check file size to ensure it's not corrupted final fileSize = await file.length(); - AppLogger.info('Installing APK from: $filePath (${fileSize} bytes)'); + 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'); + AppLogger.warning('APK file seems too small: $fileSize bytes'); } // Check file permissions @@ -81,8 +81,8 @@ class UpdateLauncher { return true; } on PlatformException catch (e) { lastException = e; - AppLogger.warning('Platform channel failed: ${e.message} (Code: ${e.code})'); - AppLogger.info('Details: ${e.details}'); + 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); diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart index f39d7b0..c8954dc 100644 --- a/lib/utils/version_utils.dart +++ b/lib/utils/version_utils.dart @@ -73,8 +73,12 @@ class VersionUtils { 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'); + 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++) { @@ -112,54 +116,61 @@ class VersionUtils { debugPrint(' New: $newVersion'); debugPrint(' New build time: $newBuildTime'); - // First, check if the versions are exactly the same (ignoring build metadata after +) - final currentClean = currentVersion.split('+')[0]; - final newClean = newVersion.split('+')[0]; - - if (currentClean == newClean) { - debugPrint(' Versions are identical (ignoring build metadata), no update needed'); - return false; - } - - // Extract the build timestamp from the current version string if possible + // Extract the build timestamp from both versions DateTime? currentBuildTime; + DateTime? newVersionBuildTime; try { - if (currentVersion.contains('nightly')) { - // Handle complex nightly version strings that may have multiple nightly parts - final nightlyRegex = RegExp(r'nightly-(\d{8})-(\d{6})'); - final matches = nightlyRegex.allMatches(currentVersion); + // 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 - if (matches.isNotEmpty) { - // Use the last (most recent) nightly timestamp in the version string - final lastMatch = matches.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'); - } + 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 current nightly version: $e'); + debugPrint('Error parsing nightly version timestamps: $e'); } - // If we can't determine build time from version string or they're too close, - // use a more lenient comparison - if (currentBuildTime == null) { - debugPrint(' Could not parse current build time, using version string comparison'); + // 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 = newBuildTime.difference(currentBuildTime); + final timeDifference = newVersionBuildTime.difference(currentBuildTime); const minimumUpdateThreshold = Duration(minutes: 5); // Minimum 5 minutes difference final isNewer = timeDifference > minimumUpdateThreshold; diff --git a/lib/widgets/activity_card.dart b/lib/widgets/activity_card.dart index 84ffbee..f8586bc 100644 --- a/lib/widgets/activity_card.dart +++ b/lib/widgets/activity_card.dart @@ -5,6 +5,7 @@ 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; @@ -254,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, @@ -298,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 9c0cbb1..bc09e0f 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -22,7 +22,6 @@ class _SpotifyWebViewLoginState extends State { String? _error; Map _extractedHeaders = {}; bool _showOverlay = true; - String _currentUrl = ''; @override Widget build(BuildContext context) { @@ -51,7 +50,7 @@ class _SpotifyWebViewLoginState extends State { Container( width: double.infinity, padding: const EdgeInsets.all(12), - color: Theme.of(context).primaryColor.withOpacity(0.1), + color: Theme.of(context).primaryColor.withValues(alpha: 26), // 0.1 * 255 โ‰ˆ 26 child: Row( children: [ Icon( @@ -75,7 +74,7 @@ class _SpotifyWebViewLoginState extends State { 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( @@ -199,9 +198,8 @@ class _SpotifyWebViewLoginState extends State { String urlString = url.toString(); AppLogger.auth('Page loaded: $urlString'); - // Update current URL and r logic + setState(() { - _currentUrl = urlString; // 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); @@ -288,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, ), @@ -297,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( @@ -732,7 +730,6 @@ class _SpotifyWebViewLoginState extends State { if (bearerToken != null && bearerToken.isNotEmpty) { AppLogger.auth('Token polling found complete result!'); - timer.cancel(); AppLogger.auth('Successfully captured Bearer token: ${bearerToken.substring(0, 20)}...'); AppLogger.debug('Token length: ${bearerToken.length} characters'); @@ -741,33 +738,101 @@ class _SpotifyWebViewLoginState extends State { AppLogger.auth('Bearer token found'); - // Complete authentication but don't navigate immediately + // 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) { - AppLogger.auth('Calling onAuthComplete with Bearer token and updated headers...'); + AppLogger.auth('Calling onAuthComplete with Bearer token and updated headers (including client-token)...'); AppLogger.debug('Final headers: ${_extractedHeaders.keys.join(', ')}'); - AppLogger.debug('Cookie header length: ${_extractedHeaders['Cookie']?.length ?? 0}'); - // Add debug logging for the callback try { - // Call the callback and wait for it to complete await widget.onAuthComplete(bearerToken, _extractedHeaders); AppLogger.auth('onAuthComplete callback executed successfully'); - // Add a longer delay to ensure all auth processing is complete await Future.delayed(const Duration(milliseconds: 500)); - // Only close the WebView after ensuring the callback completed successfully if (mounted && Navigator.canPop(context)) { AppLogger.auth('Closing WebView after successful authentication'); - // Don't return true/false, let the parent handle navigation Navigator.of(context).pop(); } } catch (e) { AppLogger.error('Error in onAuthComplete callback', e); - // If there's an error, stay on the WebView and let the user retry if (mounted) { String errorMessage = e.toString(); - // Make authentication timeout errors more user-friendly if (errorMessage.contains('Authentication verification failed after completion')) { errorMessage = 'Authentication took too long to complete. Please try again or check your internet connection.'; } @@ -777,6 +842,8 @@ class _SpotifyWebViewLoginState extends State { } } } + + timer.cancel(); } else { AppLogger.debug('Cookie update received (no token yet)'); } 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/nightly-apk.js b/nightly-apk.js index ce8260c..da1f8e4 100644 --- a/nightly-apk.js +++ b/nightly-apk.js @@ -285,6 +285,7 @@ class NightlyBuilder { 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' }); @@ -295,25 +296,52 @@ class NightlyBuilder { } 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 nightly build', 'warning'); + // 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; } - - // Build APK with nightly configuration - this.runCommand(`${flutterCommand} build apk --release --dart-define=BUILD_TYPE=nightly`, 'Building nightly APK'); + + // 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(`Nightly build failed: ${error.message}`, 'error'); + this.log(`Build failed: ${error.message}`, 'error'); return false; } } diff --git a/package.json b/package.json index a5fc6ba..399834c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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", diff --git a/pubspec.yaml b/pubspec.yaml index 5370c83..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.2-nightly-20250619-032135-nightly-20250620-050418+1750367058 +version: 0.0.2-nightly-20250629-073824+1751153904 environment: sdk: ^3.8.1 From 66f92be3e95a515031bdde6bcf1db28c50c5ca63 Mon Sep 17 00:00:00 2001 From: mliem2k Date: Sun, 29 Jun 2025 19:28:06 +0800 Subject: [PATCH 10/10] update themes --- lib/main.dart | 4 +- lib/screens/settings_screen.dart | 117 ++++++++++-------- lib/services/update_service.dart | 162 ++++++++++++++----------- lib/utils/theme.dart | 12 +- lib/widgets/spotify_webview_login.dart | 2 +- 5 files changed, 164 insertions(+), 133 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 03b944b..3bfea68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -288,7 +288,7 @@ class _MainNavigationScreenState extends State with Widget decoration: BoxDecoration( boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, -2), ), @@ -434,7 +434,7 @@ class _UpdateCheckerWrapperState extends State<_UpdateCheckerWrapper> { onPressed: _handleUpdateDownload, style: TextButton.styleFrom( foregroundColor: Colors.white, - backgroundColor: Colors.white.withOpacity(0.2), + backgroundColor: Colors.white.withValues(alpha: 0.2), ), child: const Text('Update'), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d56b1ba..f1b3a0d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -6,6 +6,7 @@ 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 StatefulWidget { const SettingsScreen({super.key}); @@ -415,23 +416,28 @@ class _SettingsScreenState extends State { // 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 - 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...')), - ], - ), - ); - }, - ); + 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 @@ -470,30 +476,36 @@ class _SettingsScreenState extends State { } } else if (updateResult.error != null) { // Show error modal with version info - _showUpdateResultDialog( - context, - 'Update Check Failed', - 'Error: ${updateResult.error}', - Icons.error, - Colors.red, - currentVersion: currentVersion, - ); + 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 - final isNightly = await UpdateService.getNightlyBuildPreference(); - 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, - ); + 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 @@ -505,6 +517,8 @@ class _SettingsScreenState extends State { if (context.mounted) { // Get current version for error display final currentVersion = await UpdateService.getCurrentAppVersion(); + if (!context.mounted) return; + _showUpdateResultDialog( context, 'Update Check Failed', @@ -543,8 +557,6 @@ class _SettingsScreenState extends State { versionInfo += 'No newer version found'; } - - showDialog( context: context, builder: (BuildContext dialogContext) { @@ -620,12 +632,15 @@ class _SettingsScreenState extends State { 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, @@ -639,8 +654,10 @@ class _SettingsScreenState extends State { } Future _performLogout(BuildContext context) async { - print('๐Ÿšช Starting logout process...'); + 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(); @@ -648,19 +665,19 @@ class _SettingsScreenState extends State { 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 diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 98ed525..53bd514 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -12,9 +12,9 @@ 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 _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'; + // static const String _nightlyInfoPath = 'nightly/latest-nightly-info.json'; // Preference keys static const String _prefLastCheckTime = 'last_update_check_time'; @@ -363,8 +363,12 @@ class UpdateService { 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'); + 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; @@ -583,9 +587,9 @@ class UpdateService { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.1), + color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange.withOpacity(0.3)), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -629,9 +633,9 @@ class UpdateService { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), + color: Colors.blue.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.blue.withOpacity(0.3)), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -662,7 +666,9 @@ class UpdateService { ) ?? false; if (!shouldRequestPermission) { - Navigator.of(context).pop(false); + if (context.mounted) { + Navigator.of(context).pop(false); + } return; } @@ -687,9 +693,9 @@ class UpdateService { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.1), + color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange.withOpacity(0.3)), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -723,20 +729,22 @@ class UpdateService { } // Show loading state - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center(child: CircularProgressIndicator()), - SizedBox(height: 16), - Text('Starting installation...'), - ], + 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); @@ -750,57 +758,61 @@ class UpdateService { // Check permission again to provide better error message final hasPermission = await UpdateLauncher.canInstallPackages(); - 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'), + 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) + ), + actions: [ + if (!hasPermission) + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await UpdateLauncher.requestInstallPermission(); + }, + child: const Text('Open Settings'), + ), TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await UpdateLauncher.requestInstallPermission(); - }, - child: const Text('Open Settings'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); + ], + ), + ); + } } else { // Installation started successfully - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Installation started! Follow the on-screen prompts.'), - backgroundColor: Colors.green, - ), - ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Installation started! Follow the on-screen prompts.'), + backgroundColor: Colors.green, + ), + ); + } } if (context.mounted) { @@ -828,7 +840,9 @@ class UpdateService { ), ); - Navigator.of(context).pop(false); + if (context.mounted) { + Navigator.of(context).pop(false); + } } } }, @@ -916,7 +930,7 @@ class UpdateInfo { 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}'); + AppLogger.info('Release body preview: ${body.length > 100 ? '${body.substring(0, 100)}...' : body}'); // Find APK asset Map? apkAsset; @@ -931,7 +945,7 @@ class UpdateInfo { final apkFileName = apkUrl.isNotEmpty ? Uri.parse(apkUrl).pathSegments.last : ''; final fileSizeBytes = apkAsset?['size'] as int? ?? 0; - AppLogger.info('Found APK asset: $apkFileName (${fileSizeBytes} bytes)'); + AppLogger.info('Found APK asset: $apkFileName ($fileSizeBytes bytes)'); // Parse release date DateTime buildDate; @@ -1084,7 +1098,7 @@ class DownloadProgress { class _DownloadProgressDialog extends StatefulWidget { final UpdateInfo updateInfo; - const _DownloadProgressDialog({Key? key, required this.updateInfo}) : super(key: key); + const _DownloadProgressDialog({required this.updateInfo}); @override _DownloadProgressDialogState createState() => _DownloadProgressDialogState(); diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index 28d5f1e..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.9), // Increased opacity for better visibility in dark mode + 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/widgets/spotify_webview_login.dart b/lib/widgets/spotify_webview_login.dart index bc09e0f..ecfa769 100644 --- a/lib/widgets/spotify_webview_login.dart +++ b/lib/widgets/spotify_webview_login.dart @@ -50,7 +50,7 @@ class _SpotifyWebViewLoginState extends State { Container( width: double.infinity, padding: const EdgeInsets.all(12), - color: Theme.of(context).primaryColor.withValues(alpha: 26), // 0.1 * 255 โ‰ˆ 26 + color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Row( children: [ Icon(