diff --git a/README.md b/README.md index cfbf4e2..732eee9 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ and previous sessions will be available for visualising. Tap on the **INFO** button to review this guide. Once you connect to the app the session manager displays a countdown -timer and buttons to interact and manage the session. +timer and buttons to interact and manage the session. As the timer +progresses, the circular progress bar fills with blue, providing a visual +cue that the session and audio are active. ![App Home Screen](screenshots/app_home_screen.png) diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index 89c2725..91f3c0d 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -1,5 +1,8 @@ # Launch Screen Assets -You can customize the launch screen with your own desired assets by replacing the image files in this directory. +You can customize the launch screen with your own desired assets by replacing +the image files in this directory. -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file +You can also do it by opening your Flutter project's Xcode project with +`open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the +Project Navigator and dropping in the desired images. diff --git a/lib/home.dart b/lib/home.dart index 54d62e6..f4b5409 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -27,6 +27,7 @@ library; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:solidui/solidui.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:innerpod/constants/colours.dart'; @@ -35,6 +36,42 @@ import 'package:innerpod/widgets/history.dart'; import 'package:innerpod/widgets/instructions.dart'; import 'package:innerpod/widgets/timer.dart'; +/// The primary widget for the app. + +class InnerPod extends StatelessWidget { + /// The primary app widget. + + const InnerPod({super.key}); + + @override + Widget build(BuildContext context) { + /// We wrap the actual home widget within a [SolidLogin]. If the app has + /// functionality that does not require access to Pod data then [required] + /// can be `false`. If the user connects to their Pod then we can ensure + /// their session information will be saved. If we aim to save the data to + /// the Pod or view data from the Pod, then if the user did not log i during + /// startup then we can call [SolidLoginPopup] to establish the connection + /// at that time. The login token and the security key are (optionally) + /// cached so that the login information is not required every time. + + return const SolidLogin( + title: 'MANAGE YOUR INNER POD', + required: false, + image: AssetImage('assets/images/inner_image.jpg'), + logo: AssetImage('assets/images/inner_icon.png'), + continueButtonStyle: ContinueButtonStyle( + text: 'Session', + background: Colors.lightGreenAccent, + ), + infoButtonStyle: InfoButtonStyle( + tooltip: 'Browse to the InnerPod home page.', + ), + link: 'https://github.com/gjwgit/innerpod/blob/dev/README.md', + child: Home(), + ); + } +} + /// A widget for the actuall app's main home page. class Home extends StatefulWidget { @@ -125,23 +162,6 @@ class HomeState extends State with SingleTickerProviderStateMixin { ), ), ), - // TODO 20250730 gjw MIGRATE TO VersionWidget - // - // I was not able to get this working - the value of _appVersion - // remains empty in the VersionWidget() even though it is correct in the - // Text(). - // - // Text('XX $_appVersion'), - // VersionWidget( - // version: 'YY $_appVersion', - // changelogUrl: _changelogUrl, - // showDate: false, - // userTextStyle: const TextStyle( - // color: Colors.deepPurple, - // fontSize: 10, - // ), - // ), - // Text('Version $_appVersion', style: const TextStyle(fontSize: 10)), const SizedBox(width: 50), IconButton( icon: const Icon(Icons.info), diff --git a/lib/main.dart b/lib/main.dart index 771e145..2995a56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,8 +27,6 @@ library; import 'package:flutter/material.dart'; -import 'package:solidui/solidui.dart'; - import 'package:innerpod/home.dart'; //import 'package:innerpod/timer.dart'; @@ -41,39 +39,3 @@ void main() { runApp(const MaterialApp(title: 'Inner Pod', home: InnerPod())); } - -/// The primary widget for the app. - -class InnerPod extends StatelessWidget { - /// The primary app widget. - - const InnerPod({super.key}); - - @override - Widget build(BuildContext context) { - /// We wrap the actual home widget within a [SolidLogin]. If the app has - /// functionality that does not require access to Pod data then [required] - /// can be `false`. If the user connects to their Pod then we can ensure - /// their session information will be saved. If we aim to save the data to - /// the Pod or view data from the Pod, then if the user did not log i during - /// startup then we can call [SolidLoginPopup] to establish the connection - /// at that time. The login token and the security key are (optionally) - /// cached so that the login information is not required every time. - - return const SolidLogin( - title: 'MANAGE YOUR INNER POD', - required: false, - image: AssetImage('assets/images/inner_image.jpg'), - logo: AssetImage('assets/images/inner_icon.png'), - continueButtonStyle: ContinueButtonStyle( - text: 'Session', - background: Colors.lightGreenAccent, - ), - infoButtonStyle: InfoButtonStyle( - tooltip: 'Browse to the InnerPod home page.', - ), - link: 'https://github.com/gjwgit/innerpod/blob/dev/README.md', - child: Home(), - ); - } -} diff --git a/lib/utils/session_logic.dart b/lib/utils/session_logic.dart index 4e64b8c..7284ac3 100644 --- a/lib/utils/session_logic.dart +++ b/lib/utils/session_logic.dart @@ -46,9 +46,11 @@ List> parseSessions(String? content) { final RegExp sessionBlockRegExp = RegExp(r':session_\d+.*?\.(?:\s+|$)', dotAll: true); - // RegExp to extract start and end times within a block + // RegExp to extract properties within a block final RegExp startRegExp = RegExp(r':start "(.*?)"\^\^xsd:dateTime'); final RegExp endRegExp = RegExp(r':end "(.*?)"\^\^xsd:dateTime'); + final RegExp typeRegExp = RegExp(r':type "(.*?)"'); + final RegExp durationRegExp = RegExp(r':silenceDuration (\d+)'); final matches = sessionBlockRegExp.allMatches(content); @@ -56,11 +58,15 @@ List> parseSessions(String? content) { final block = match.group(0)!; final startMatch = startRegExp.firstMatch(block); final endMatch = endRegExp.firstMatch(block); + final typeMatch = typeRegExp.firstMatch(block); + final durationMatch = durationRegExp.firstMatch(block); if (startMatch != null && endMatch != null) { sessions.add({ 'start': startMatch.group(1)!, 'end': endMatch.group(1)!, + 'type': typeMatch?.group(1) ?? 'basic', + 'silenceDuration': durationMatch?.group(1) ?? '1200', // Default to 20m }); } } @@ -87,6 +93,9 @@ String addSession(String? currentContent, Map newSession) { final String start = newSession['start']; final String end = newSession['end']; + final String type = newSession['type'] ?? 'basic'; + final int silenceDuration = newSession['silenceDuration'] ?? 1200; + // Use timestamp as unique ID final String id = DateTime.parse(start).millisecondsSinceEpoch.toString(); @@ -97,7 +106,9 @@ String addSession(String? currentContent, Map newSession) { $separator :session_$id a :Session; :start "$start"^^xsd:dateTime; - :end "$end"^^xsd:dateTime. + :end "$end"^^xsd:dateTime; + :type "$type"; + :silenceDuration $silenceDuration. '''; return content + newEntry; diff --git a/lib/widgets/about.dart b/lib/widgets/about.dart index 643a10d..0e11e69 100644 --- a/lib/widgets/about.dart +++ b/lib/widgets/about.dart @@ -44,8 +44,10 @@ Future showAppAboutDialog(BuildContext context) async { Inner Pod is an app for timing meditation sessions and, optionally, storing a log of your meditation sessions to your Pod. A session, in fact, can be anything. The app is commonly used for contemplative or silent meditation as - is the tradition in many cultures and religions. The concept for the app and - images were generated by large language models. + is the tradition in many cultures and religions. The blue progress circle + provides a visual cue that the session is active and audio may be + playing. The concept for the app and images were generated by large + language models. The app is written in Flutter and the open source code is available from **github**. You can also run the app **online** through your browser. diff --git a/lib/widgets/history.dart b/lib/widgets/history.dart index c44418d..76b3759 100644 --- a/lib/widgets/history.dart +++ b/lib/widgets/history.dart @@ -42,34 +42,35 @@ class _HistoryState extends State { String? content; try { content = await readPod('sessions.ttl'); - } catch (e) { + } on ResourceNotExistException { // If file doesn't exist yet, treat as empty (no sessions) - debugPrint('sessions.ttl does not exist yet: $e'); + debugPrint('sessions.ttl does not exist yet (normal for new users)'); + content = null; + } catch (e) { + debugPrint('Error reading from Pod: $e'); content = null; } // parseSessions handles null content and returns empty list List jsonList = parseSessions(content); - if (jsonList.isNotEmpty) { - if (mounted) { - setState(() { - _sessions = jsonList.map((item) { - final start = DateTime.parse(item['start']); - final end = DateTime.parse(item['end']); - return { - 'date': DateFormat('yyyy-MM-dd').format(start), - 'start': DateFormat('HH:mm:ss').format(start), - 'end': DateFormat('HH:mm:ss').format(end), - }; - }).toList(); - }); - } + if (mounted) { + setState(() { + _sessions = jsonList.map((item) { + final start = DateTime.parse(item['start']); + final end = DateTime.parse(item['end']); + return { + 'date': DateFormat('yyyy-MM-dd').format(start), + 'start': DateFormat('HH:mm:ss').format(start), + 'end': DateFormat('HH:mm:ss').format(end), + 'type': (item['type'] ?? 'basic') as String, + 'duration': + '${(int.parse(item['silenceDuration'] ?? '1200') / 60).round()}m', + }; + }).toList(); + }); } } catch (e) { - // If file doesn't exist yet, treat as empty (no sessions) - debugPrint( - 'sessions.ttl does not exist yet (this is normal for new users)', - ); + debugPrint('Unexpected error loading sessions: $e'); } finally { if (mounted) { setState(() { @@ -104,6 +105,8 @@ class _HistoryState extends State { child: DataTable( columns: const [ DataColumn(label: Text('Date')), + DataColumn(label: Text('Type')), + DataColumn(label: Text('Min')), DataColumn(label: Text('Start')), DataColumn(label: Text('End')), ], @@ -111,6 +114,8 @@ class _HistoryState extends State { return DataRow( cells: [ DataCell(Text(session['date']!)), + DataCell(Text(session['type']!)), + DataCell(Text(session['duration']!)), DataCell(Text(session['start']!)), DataCell(Text(session['end']!)), ], diff --git a/lib/widgets/timer.dart b/lib/widgets/timer.dart index 2654fb1..28b209e 100644 --- a/lib/widgets/timer.dart +++ b/lib/widgets/timer.dart @@ -75,14 +75,14 @@ class TimerState extends State { var _duration = defaultSessionSeconds; - // Track the duration of a loaded audio file. - - var _audioDuration = Duration.zero; - // Track the start time of a session. DateTime? _startTime; + // Track the session type. + + String _sessionType = 'basic'; + //////////////////////////////////////////////////////////////////////// // CONSTANTS //////////////////////////////////////////////////////////////////////// @@ -98,10 +98,6 @@ class TimerState extends State { final _player = AudioPlayer(); - // Subscription for audio duration changes. - - StreamSubscription? _durationSubscription; - //////////////////////////////////////////////////////////////////////// // SLEEP //////////////////////////////////////////////////////////////////////// @@ -117,21 +113,27 @@ class TimerState extends State { @override void initState() { super.initState(); - - // Listen to the duration of the audio file being played. - - _durationSubscription = _player.onDurationChanged.listen((d) { - _audioDuration = d; - }); } @override void dispose() { - _durationSubscription?.cancel(); _player.dispose(); super.dispose(); } + /// Helper to play an audio source and wait for it to complete. + Future _play(Source source) async { + if (!mounted) return; + try { + await _player.stop(); + await _player.play(source); + // Wait for the audio to finish playing. + await _player.onPlayerComplete.first; + } catch (e) { + debugPrint('Audio playback error or interrupted: $e'); + } + } + //////////////////////////////////////////////////////////////////////// // RESET //////////////////////////////////////////////////////////////////////// @@ -155,6 +157,7 @@ class TimerState extends State { _reset(); _stopSleep(); _isGuided = false; + _sessionType = 'intro'; _startTime = DateTime.now(); // Good to wait a second before starting the audio after tapping the button, @@ -163,20 +166,8 @@ class TimerState extends State { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; - // Make sure there is no other audio playing just now and then start the - // intro audio. - - await _player.stop(); - if (!mounted) return; - await _player.play(introAudio); - - debugPrint('INTRO: intro waiting $_audioDuration'); - - // Wait now while the intro audio is played before the dong when the timer - // then actually starts. - - //await Future.delayed(Duration(seconds: _introTime)); - await Future.delayed(_audioDuration); + // Play and wait for the intro audio. + await _play(introAudio); if (!mounted) return; // Good to wait another 1 second here before the dings after the @@ -185,7 +176,7 @@ class TimerState extends State { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; - await dingDong(_player); + await _play(dong); if (!mounted) return; _controller.restart(); } @@ -204,6 +195,7 @@ class TimerState extends State { _reset(); _stopSleep(); _isGuided = true; + _sessionType = 'guided'; _startTime = DateTime.now(); // Good to wait a second before starting the audio after tapping the button, @@ -213,13 +205,7 @@ class TimerState extends State { if (!mounted) return; // Play and wait for the session guide audio to finish. - - await _player.stop(); - await _player.play(sessionGuide); - - debugPrint('GUIDED: guide waiting $_audioDuration'); - - await Future.delayed(_audioDuration); + await _play(sessionGuide); if (!mounted) return; // Good to wait a second before the dings otherwise it feels rushed coming @@ -228,11 +214,9 @@ class TimerState extends State { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; - // The introductions are complete. We now tell the device not to sleep, play - // the dings, and start the timer. + // The introductions are complete. We play the dings and start the timer. - await dingDong(_player); - debugPrint('GUIDED: dong waiting $_audioDuration'); + await _play(dong); if (!mounted) return; _controller.restart(); } @@ -245,33 +229,30 @@ class TimerState extends State { // What to do at the end of a session. logMessage('Session Completed'); - if (mounted) { - await _player.play(dong); - debugPrint('COMPLETE: dong waiting: $_audioDuration'); - await Future.delayed(_audioDuration); - } - if (!mounted) { - await _saveSession(); - return; + // Only play audio and wait if still mounted + if (mounted) { + await _play(dong); } - if (_isGuided) { - await _player.play(sessionOutro); - debugPrint('COMPLETE: outro waiting: $_audioDuration'); - await Future.delayed(_audioDuration); - if (!mounted) { - await _saveSession(); - return; + // Check mounted state again after the dings + if (mounted && _isGuided) { + // Add a small delay between the dings and the outro music for smoother transition + // especially on systems with busy audio pipes (like Linux with audio sharing). + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + await _play(sessionOutro); } } - // Check if widget is still mounted before calling _reset() - // to avoid AnimationController errors after disposal + // Reset controls only if still mounted to avoid AnimationController errors if (mounted) { _reset(); _allowSleep(); } + + // Always attempt to save the session, even if navigate away + // _saveSession handles its own internal null checks await _saveSession(); } @@ -282,15 +263,20 @@ class TimerState extends State { final session = { 'start': _startTime!.toIso8601String(), 'end': endTime.toIso8601String(), + 'type': _sessionType, + 'silenceDuration': _duration, }; try { String? content; try { content = await readPod('sessions.ttl'); + } on ResourceNotExistException { + // File doesn't exist yet, we'll create it. + debugPrint('sessions.ttl does not exist, creating new file.'); + content = null; } catch (e) { - // If the file does not exist (e.g., first session), we treat it as - // null. + logMessage('Error reading sessions.ttl: $e'); content = null; } String newContent = addSession(content, session); @@ -324,7 +310,8 @@ class TimerState extends State { tooltip: ''' Tap here to begin a session of silence for ${(_duration / 60).round()} -minutes, beginning and ending with three chimes. +minutes, beginning and ending with three chimes. The blue progress +circle indicates an active session. ''' .trim(), @@ -334,6 +321,7 @@ minutes, beginning and ending with three chimes. dingDong(_player); _controller.restart(); _stopSleep(); + _sessionType = 'basic'; _startTime = DateTime.now(); }, fontWeight: FontWeight.bold, @@ -387,7 +375,7 @@ of the Resume button. Tap here to play a short introduction for a session. After the introduction a ${(_duration / 60).round()} minute session of silence will begin and end with -three dings. +three dings. The blue progress circle indicates an active session. ''' .trim(), @@ -403,7 +391,8 @@ three dings. Tap here to play a ${10 + (_duration / 60).round()} minute guided session. The session begins with instructions for meditation from John Main. Introductory music is followed by three chimes and a ${(_duration / 60).round()} -minute silent session which is then finished with another three chimes. The +minute silent session which is then finished with another three chimes. The +blue progress circle indicates an active session. The audio may take a little time to download for the Web version. '''