diff --git a/.lycheeignore b/.lycheeignore index 58bd48b..d69b336 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -45,6 +45,8 @@ web/** # Placeholder URLs in documentation https://github.com/YOUR_USERNAME/innerpod.git +https://github.com/gjwgit/innerpod/blob/dev/README.md + # Licenses. Note that gnu.org is throttled so do not check it. diff --git a/lib/home.dart b/lib/home.dart index d6094e2..54d62e6 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -1,12 +1,12 @@ -/// A session timer with session logged to your Solid Pod. +// A session timer with session logged to your Solid Pod. // // Time-stamp: // -/// Copyright (C) 2024-2025, Togaware Pty Ltd -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"); -/// -/// License: https://opensource.org/license/gpl-3-0 +// Copyright (C) 2024-2025, Togaware Pty Ltd +// +// Licensed under the GNU General Public License, Version 3 (the "License"); +// +// License: https://opensource.org/license/gpl-3-0 // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software @@ -22,19 +22,15 @@ // this program. If not, see . /// /// Authors: Graham Williams - library; import 'package:flutter/material.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:gap/gap.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:solidpod/solidpod.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:innerpod/constants/colours.dart'; -import 'package:innerpod/utils/word_wrap.dart'; +import 'package:innerpod/widgets/about.dart'; import 'package:innerpod/widgets/history.dart'; import 'package:innerpod/widgets/instructions.dart'; import 'package:innerpod/widgets/timer.dart'; @@ -60,31 +56,15 @@ class HomeState extends State with SingleTickerProviderStateMixin { final String _changelogUrl = 'https://github.com/gjwgit/innerpod/blob/dev/CHANGELOG.md'; - final _about = wordWrap(''' - - 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. - - 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. - - **GitHub** [https://github.com/gjwgit/innerpod](https://github.com/gjwgit/innerpod) - - **OnLine** [https://innerpod.solidcommunity.au](https://innerpod.solidcommunity.au) - - **Author** [Graham Williams](https://togaware.com/graham.williams.html) - '''); - // Helper function to load the app name and version. Future _loadAppInfo() async { final packageInfo = await PackageInfo.fromPlatform(); - setState(() { - _appVersion = packageInfo.version; // Set app version from package info - }); + if (mounted) { + setState(() { + _appVersion = packageInfo.version; // Set app version from package info + }); + } } @override @@ -165,40 +145,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { const SizedBox(width: 50), IconButton( icon: const Icon(Icons.info), - onPressed: () async { - final appInfo = await getAppNameVersion(); - final appName = appInfo.name; - - // Note the use of the conditional with `context.mounted` to avoid - // the "Don't use 'BuildContext's across async gaps" warning. - - if (context.mounted) { - showAboutDialog( - context: context, - applicationIcon: Image.asset( - 'assets/images/inner_icon.png', - width: 100, - height: 100, - ), - applicationName: - '${appName[0].toUpperCase()}${appName.substring(1)}', - applicationVersion: appInfo.version, - applicationLegalese: '© 2024 Togaware', - children: [ - const Gap(20), - MarkdownBody( - data: _about, - selectable: true, - softLineBreak: true, - onTapLink: (text, href, about) { - final url = Uri.parse(href ?? ''); - launchUrl(url); - }, - ), - ], - ); - } - }, + onPressed: () => showAppAboutDialog(context), tooltip: 'Popup a window about the app.', ), ], diff --git a/lib/widgets/about.dart b/lib/widgets/about.dart new file mode 100644 index 0000000..643a10d --- /dev/null +++ b/lib/widgets/about.dart @@ -0,0 +1,85 @@ +// About dialog for the app. +// +// Time-stamp: <2026-02-12 20:40:00 Graham Williams> +// +// Copyright (C) 2024-2026, Togaware Pty Ltd +// +// Licensed under the GNU General Public License, Version 3 (the "License"); +// +// License: https://opensource.org/license/gpl-3-0 +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Graham Williams +library; + +import 'package:flutter/material.dart'; + +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:gap/gap.dart'; +import 'package:solidpod/solidpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:innerpod/utils/word_wrap.dart'; + +/// Display the about dialog for the app. + +Future showAppAboutDialog(BuildContext context) async { + final appInfo = await getAppNameVersion(); + final appName = appInfo.name; + + final about = wordWrap(''' + + 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. + + 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. + + **GitHub** [https://github.com/gjwgit/innerpod](https://github.com/gjwgit/innerpod) + + **OnLine** [https://innerpod.solidcommunity.au](https://innerpod.solidcommunity.au) + + **Author** [Graham Williams](https://togaware.com/graham.williams.html) + '''); + + if (context.mounted) { + showAboutDialog( + context: context, + applicationIcon: Image.asset( + 'assets/images/inner_icon.png', + width: 100, + height: 100, + ), + applicationName: '${appName[0].toUpperCase()}${appName.substring(1)}', + applicationVersion: appInfo.version, + applicationLegalese: '© 2024 Togaware', + children: [ + const Gap(20), + MarkdownBody( + data: about, + selectable: true, + softLineBreak: true, + onTapLink: (text, href, about) { + final url = Uri.parse(href ?? ''); + launchUrl(url); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/history.dart b/lib/widgets/history.dart index f5411f0..c44418d 100644 --- a/lib/widgets/history.dart +++ b/lib/widgets/history.dart @@ -32,9 +32,11 @@ class _HistoryState extends State { } Future _loadSessions() async { - setState(() { - _isLoading = true; - }); + if (mounted) { + setState(() { + _isLoading = true; + }); + } try { String? content; @@ -49,24 +51,31 @@ class _HistoryState extends State { // parseSessions handles null content and returns empty list List jsonList = parseSessions(content); if (jsonList.isNotEmpty) { - 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), + }; + }).toList(); + }); + } } } catch (e) { - debugPrint('Unexpected error loading sessions: $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)', + ); } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } diff --git a/lib/widgets/timer.dart b/lib/widgets/timer.dart index 3b3ab31..2654fb1 100644 --- a/lib/widgets/timer.dart +++ b/lib/widgets/timer.dart @@ -1,12 +1,12 @@ -/// A countdown timer and buttons for a session. +// A countdown timer and buttons for a session. // // Time-stamp: // -/// Copyright (C) 2024, Togaware Pty Ltd -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"); -/// -/// License: https://opensource.org/license/gpl-3-0 +// Copyright (C) 2024, Togaware Pty Ltd +// +// Licensed under the GNU General Public License, Version 3 (the "License"); +// +// License: https://opensource.org/license/gpl-3-0 // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software @@ -22,9 +22,10 @@ // this program. If not, see . /// /// Authors: Graham Williams - library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; @@ -97,6 +98,10 @@ class TimerState extends State { final _player = AudioPlayer(); + // Subscription for audio duration changes. + + StreamSubscription? _durationSubscription; + //////////////////////////////////////////////////////////////////////// // SLEEP //////////////////////////////////////////////////////////////////////// @@ -109,6 +114,24 @@ class TimerState extends State { void _stopSleep() => WakelockPlus.enable(); + @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(); + } + //////////////////////////////////////////////////////////////////////// // RESET //////////////////////////////////////////////////////////////////////// @@ -128,6 +151,7 @@ class TimerState extends State { // An audio is played and then we begin the session. logMessage('Start Intro Session'); + if (!mounted) return; _reset(); _stopSleep(); _isGuided = false; @@ -137,11 +161,13 @@ class TimerState extends State { // otherwise it feels rushed. 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'); @@ -151,13 +177,16 @@ class TimerState extends State { //await Future.delayed(Duration(seconds: _introTime)); await Future.delayed(_audioDuration); + if (!mounted) return; // Good to wait another 1 second here before the dings after the // introduction audio, otherwise it feels rushed. await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; await dingDong(_player); + if (!mounted) return; _controller.restart(); } @@ -171,6 +200,7 @@ class TimerState extends State { // another musical interlude. logMessage('Start Guided Session'); + if (!mounted) return; _reset(); _stopSleep(); _isGuided = true; @@ -180,6 +210,7 @@ class TimerState extends State { // otherwise it feels rushed. await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; // Play and wait for the session guide audio to finish. @@ -189,17 +220,20 @@ class TimerState extends State { debugPrint('GUIDED: guide waiting $_audioDuration'); await Future.delayed(_audioDuration); + if (!mounted) return; // Good to wait a second before the dings otherwise it feels rushed coming // straight from the music. 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. await dingDong(_player); debugPrint('GUIDED: dong waiting $_audioDuration'); + if (!mounted) return; _controller.restart(); } @@ -211,14 +245,25 @@ class TimerState extends State { // What to do at the end of a session. logMessage('Session Completed'); - await _player.play(dong); - debugPrint('COMPLETE: dong waiting: $_audioDuration'); - await Future.delayed(_audioDuration); + if (mounted) { + await _player.play(dong); + debugPrint('COMPLETE: dong waiting: $_audioDuration'); + await Future.delayed(_audioDuration); + } + + if (!mounted) { + await _saveSession(); + return; + } if (_isGuided) { await _player.play(sessionOutro); debugPrint('COMPLETE: outro waiting: $_audioDuration'); await Future.delayed(_audioDuration); + if (!mounted) { + await _saveSession(); + return; + } } // Check if widget is still mounted before calling _reset() @@ -244,12 +289,10 @@ class TimerState extends State { try { content = await readPod('sessions.ttl'); } catch (e) { - // If file doesn't exist yet, treat as empty content - // This will create the file with proper prefixes - debugPrint('sessions.ttl does not exist yet, creating new file'); + // If the file does not exist (e.g., first session), we treat it as + // null. content = null; } - String newContent = addSession(content, session); await writePod('sessions.ttl', newContent); logMessage('Session saved to Pod'); @@ -268,15 +311,6 @@ class TimerState extends State { Widget build(BuildContext context) { // Build the Timer Widget. - // Add a listener for a change in the duration of the playing audio - // file. When the audio is loaded from file then take note of the duration - // of the audio which will then be used to pause until the audio is - // complete. 20240329 gjw - - _player.onDurationChanged.listen((d) { - _audioDuration = d; - }); - //////////////////////////////////// // APP BUTTONS //////////////////////////////////// @@ -412,64 +446,80 @@ audio may take a little time to download for the Web version. // RETURN //////////////////////////////////// - return SingleChildScrollView( - child: Padding( - // Add some top and bottom padding so the timer is not clipped at the - // top nor the chips at the bottom. - padding: const EdgeInsets.only(top: 10, bottom: 5), - child: Column( + final timerDisplay = AppCircularCountDownTimer( + duration: _duration, + controller: _controller, + onComplete: _complete, + ); + + final buttonsMatrix = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - AppCircularCountDownTimer( - duration: _duration, - controller: _controller, - onComplete: _complete, - ), - const SizedBox(height: 2 * heightSpacer), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - introButton, - const SizedBox(width: widthSpacer), - startButton, - ], - ), - const SizedBox(height: heightSpacer), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - guidedButton, - const SizedBox(width: widthSpacer), - pauseButton, - ], - ), - // TODO 20240707 gjw EVENTUALLY PUT RESUME AND RESET INTO PAUSE. - // - // A long press will be RESET. On tap PASUE turn the button - // into RESUME. - // - // const SizedBox(height: heightSpacer), - // Row( - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // resetButton, - // const SizedBox(width: widthSpacer), - // resumeButton, - // ], - // ), - const SizedBox(height: 2 * heightSpacer), - const Text( - 'Select duration (minutes)', - style: TextStyle(fontSize: 20.0, color: Colors.grey), - ), - const SizedBox(height: 1 * heightSpacer), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [durationChoice], - ), + introButton, + const SizedBox(width: widthSpacer), + startButton, ], ), - ), + const SizedBox(height: heightSpacer), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + guidedButton, + const SizedBox(width: widthSpacer), + pauseButton, + ], + ), + const SizedBox(height: 2 * heightSpacer), + const Text( + 'Select duration (minutes)', + style: TextStyle(fontSize: 20.0, color: Colors.grey), + ), + const SizedBox(height: 1 * heightSpacer), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [durationChoice], + ), + ], + ); + + return OrientationBuilder( + builder: (context, orientation) { + if (orientation == Orientation.portrait) { + return SingleChildScrollView( + child: Padding( + // Add some top and bottom padding so the timer is not clipped at the + // top nor the chips at the bottom. + padding: const EdgeInsets.only(top: 10, bottom: 5), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + timerDisplay, + const SizedBox(height: 2 * heightSpacer), + buttonsMatrix, + ], + ), + ), + ); + } else { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: Center(child: timerDisplay)), + const SizedBox(width: 2 * widthSpacer), + Expanded(child: Center(child: buttonsMatrix)), + ], + ), + ), + ); + } + }, ); } }